Usman Bari commited on
Commit
37a6ee1
·
0 Parent(s):

Initial commit

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.template +2 -0
  2. .gitignore +13 -0
  3. README.md +147 -0
  4. backend/__init__.py +1 -0
  5. backend/config.py +31 -0
  6. backend/main.py +754 -0
  7. backend/requirements.txt +7 -0
  8. backend/schemas.py +184 -0
  9. backend/services/__init__.py +11 -0
  10. backend/services/csv_service.py +196 -0
  11. backend/services/insight_service.py +77 -0
  12. backend/services/llm_service.py +446 -0
  13. backend/services/schema_service.py +923 -0
  14. backend/services/sql_service.py +156 -0
  15. frontend/.gitignore +24 -0
  16. frontend/README.md +16 -0
  17. frontend/eslint.config.js +21 -0
  18. frontend/index.html +13 -0
  19. frontend/package-lock.json +2438 -0
  20. frontend/package.json +27 -0
  21. frontend/public/favicon.svg +1 -0
  22. frontend/public/icons.svg +24 -0
  23. frontend/src/App.css +184 -0
  24. frontend/src/App.jsx +409 -0
  25. frontend/src/api.js +157 -0
  26. frontend/src/assets/hero.png +0 -0
  27. frontend/src/assets/react.svg +1 -0
  28. frontend/src/assets/vite.svg +1 -0
  29. frontend/src/components/ChartView.jsx +550 -0
  30. frontend/src/components/DataPreview.jsx +99 -0
  31. frontend/src/components/DataTable.jsx +64 -0
  32. frontend/src/components/LoadingResult.jsx +44 -0
  33. frontend/src/components/QueryInput.jsx +83 -0
  34. frontend/src/components/QueryResult.jsx +332 -0
  35. frontend/src/components/QueryView.jsx +289 -0
  36. frontend/src/components/SchemaPreviewPanel.jsx +679 -0
  37. frontend/src/components/SchemaQueryView.jsx +261 -0
  38. frontend/src/components/SchemaUploadModal.jsx +422 -0
  39. frontend/src/components/Sidebar.jsx +324 -0
  40. frontend/src/components/UploadView.jsx +346 -0
  41. frontend/src/index.css +924 -0
  42. frontend/src/main.jsx +10 -0
  43. frontend/vite.config.js +7 -0
  44. run_tests.py +138 -0
  45. sample_data/ecommerce.csv +36 -0
  46. sample_data/employees.csv +26 -0
  47. sample_data/sales.csv +31 -0
  48. sample_data/schema/ecommerce.db +0 -0
  49. sample_data/schema/ecommerce_schema.sql +25 -0
  50. sample_data/schema/generate_ecommerce_db.py +162 -0
.env.template ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Groq API Key for llama3-8b-8192
2
+ GROQ_API_KEY=your_groq_api_key_here
.gitignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ node_modules/
2
+ __pycache__/
3
+ venv/
4
+ env/
5
+ .env
6
+ databases/csv/*.db
7
+ databases/schema/*.db
8
+ databases/schema/*.sql
9
+ databases/schema/*.json
10
+ *.pyc
11
+ .DS_Store
12
+ dist/
13
+ build/
README.md ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Natural Language Data Analyst
2
+
3
+ ## Overview
4
+ A powerful full-stack analytical web application that enables users to query structured databases using plain, conversational English. The system automatically converts natural language questions into highly optimized, dialect-specific SQL, executes it against an embedded SQLite database, renders beautiful responsive charts (pure SVG without third-party chart libraries), and synthesizes qualitative business insights from the results using generative AI.
5
+
6
+ ## Features
7
+ - 🗣️ **Natural language to SQL conversion**: Translates conversational questions into accurate SQLite SELECT queries.
8
+ - 📊 **Auto-generated charts**: Renders pure-SVG Bar, Line, and Pie charts dynamically based on query results column metadata (completely zero-dependency).
9
+ - 💡 **AI-generated insights**: Summarizes query execution results into concise business insights in real time.
10
+ - 📁 **Upload your own CSV datasets**: Seamlessly upload custom CSV files which are automatically formatted, sanitized, and loaded into individual SQLite database files.
11
+ - 🗃️ **Built-in sample datasets**: Pre-loaded with Sales, Employees, and E-commerce sample data to start querying immediately.
12
+ - 🔄 **Auto-retry on SQL errors**: Automatically catches execution syntax/type failures and prompts the LLM to fix the query (up to 3 retries).
13
+ - 📥 **Export results as CSV**: Downloads query data instantly as CSV files named based on the source question.
14
+ - 💡 **Query Suggestions**: Generates and caches 6 interesting question suggestions using AI when a dataset is opened.
15
+ - 🕒 **Recent Queries Log**: Retains the last 20 queries run during a session for rapid re-execution.
16
+ - ⌨️ **Keyboard Shortcuts**: Focuses input with `Ctrl+K`, submits with `Ctrl+Enter`, and pulls the last query using the `Arrow Up` key when empty.
17
+
18
+ ## Tech Stack
19
+
20
+ | Technology | Purpose |
21
+ |---|---|
22
+ | **Python 3.10+ / FastAPI** | Backend web server and REST API routing |
23
+ | **Groq (Llama 3.1 8B)** | Advanced SQL query generation, self-correction, suggestions, and text insights |
24
+ | **SQLite + Pandas** | SQL database storage, schema analysis, and query execution |
25
+ | **React + Vite** | High-performance, reactive single-page frontend application |
26
+ | **Pure CSS / HTML5** | Modern, premium glassmorphism dark-mode styling and UI aesthetics |
27
+ | **Pure SVG** | High-fidelity interactive charts and visual graphs (zero-dependency) |
28
+
29
+ ---
30
+
31
+ ## Project Structure
32
+
33
+ ```text
34
+ nl-data-analyst/
35
+ ├── backend/
36
+ │ ├── main.py # FastAPI routing, CORS middleware, and cache setup
37
+ │ ├── config.py # Global server constants, LLM model selection, and API keys
38
+ │ ├── schemas.py # Pydantic models validating REST request and response contracts
39
+ │ ├── requirements.txt # Python dependency manifest (FastAPI, Groq, Pandas)
40
+ │ └── services/ # Core business logic handlers
41
+ │ ├── csv_service.py # CSV loading, parsing, and SQLite DB generation
42
+ │ ├── sql_service.py # SQLite execution, query sanitization, and safety audits
43
+ │ ├── llm_service.py # SQL generation, self-repair pipelines, and suggestions
44
+ │ └── insight_service.py # Text synthesis translating tabular data to business insights
45
+ ├── databases/ # Storage directory containing generated SQLite .db files
46
+ ├── sample_data/ # Built-in sample CSV datasets (Sales, Employees, E-commerce)
47
+ ├── frontend/
48
+ │ ├── src/
49
+ │ │ ├── components/ # Reusable React UI component layers
50
+ │ │ │ ├── Sidebar.jsx # Side menu listing datasets, upload buttons, and recent logs
51
+ │ │ │ ├── UploadView.jsx # Drag-and-drop CSV upload landing zone
52
+ │ │ │ ├── DataPreview.jsx # Overview statistics, column schema, and data grid preview
53
+ │ │ │ ├── QueryView.jsx # Context bars, query log panels, and suggestions decks
54
+ │ │ │ ├── QueryInput.jsx # Query textarea form with key shortcut binds
55
+ │ │ │ ├── QueryResult.jsx # Collapsible SQL, data table, and chart selector display
56
+ │ │ │ ├── ChartView.jsx # SVG chart routing, scaling, grid plotting, and tooltips
57
+ │ │ │ ├── DataTable.jsx # Interactive data matrix grid
58
+ │ │ │ └── LoadingResult.jsx # Dynamic shimmers skeleton loading placeholders
59
+ │ │ ├── api.js # JavaScript fetch integration layer mapping REST endpoints
60
+ │ │ ├── App.jsx # Top-level state coordinator, stats bar, and toast managers
61
+ │ │ ├── index.css # Global custom CSS styles, animations, and responsive queries
62
+ │ │ └── main.jsx # React client DOM mounter
63
+ │ ├── package.json # Node.js dev server and build tool dependencies
64
+ │ ├── vite.config.js # Vite custom proxy and build settings
65
+ │ └── index.html # Main HTML skeleton file
66
+ ├── .env # Environment credentials (API keys, ports)
67
+ └── README.md # Project documentation manual
68
+ ```
69
+
70
+ ---
71
+
72
+ ## Getting Started
73
+
74
+ ### Prerequisites
75
+ - **Python 3.10+**
76
+ - **Node.js 18+**
77
+ - **Groq API Key** (obtain free from [console.groq.com](https://console.groq.com))
78
+
79
+ ### Installation & Running
80
+
81
+ #### 1. Setup the Backend
82
+ Navigate to the root directory and create a virtual environment:
83
+ ```bash
84
+ # Create python virtual environment
85
+ python -m venv .venv
86
+
87
+ # Activate the virtual environment
88
+ # On Windows (Command Prompt/PowerShell):
89
+ .venv\Scripts\activate
90
+ # On macOS/Linux:
91
+ source .venv/bin/activate
92
+
93
+ # Install backend dependencies
94
+ pip install -r backend/requirements.txt
95
+ ```
96
+
97
+ Create a `.env` configuration file in the project root (using `.env.template` as a model) and fill in your Groq API Key:
98
+ ```env
99
+ GROQ_API_KEY=gsk_your_actual_groq_api_key_here
100
+ PORT=8000
101
+ ```
102
+
103
+ Start the backend FastAPI server:
104
+ ```bash
105
+ # Run the backend using Uvicorn
106
+ python -m uvicorn backend.main:app --host 127.0.0.1 --port 8000 --reload
107
+ ```
108
+ The API documentation will be available at `http://127.0.0.1:8000/docs`.
109
+
110
+ #### 2. Setup the Frontend
111
+ Open a new terminal session, navigate to the `frontend/` directory, install packages, and start the development server:
112
+ ```bash
113
+ cd frontend
114
+
115
+ # Install client packages
116
+ npm install
117
+
118
+ # Start Vite dev environment
119
+ npm run dev -- --port 5173 --host 127.0.0.1
120
+ ```
121
+ The web application is now active at `http://127.0.0.1:5173/`.
122
+
123
+ ---
124
+
125
+ ## How It Works
126
+
127
+ 1. **Dataset Selection & Conversion**: The user selects a preloaded dataset or drops in a custom CSV file. The backend parses it using Pandas and maps datatypes to create an optimized SQLite database file inside the `databases/` folder.
128
+ 2. **Dynamic Question Suggestions**: When a dataset is loaded, its schema is analysed and passed to the Llama model on Groq to compile 6 business-relevant query suggestions. Suggestions are cached in-memory.
129
+ 3. **Natural Language Translation**: The user submits a conversational question. The LLM translates the query based on the table schema and SQL formatting system prompts.
130
+ 4. **Execution & Self-Correction**: The SQLite query is verified against destructive SQL keywords (only SELECTs allowed) and executed. If SQLite throws a syntax error, the LLM-service receives the failing SQL and stacktrace to attempt self-correction.
131
+ 5. **Visualization Layout**: The frontend parses the output matrix. If column headers contain labels + numbers, it charts the data:
132
+ - **Pie Chart**: Used for $\le 6$ distinct labels (great for composition summaries).
133
+ - **Bar Chart**: Generates dynamic scale sizes and plots vertical bars with custom hover tooltips.
134
+ - **Line Chart**: Draws line series, highlights points, and adds linear gradients under the curves.
135
+ 6. **Insight Synthesis**: Groq translates the final matrix rows and original question to output a concise 2-3 sentence business summary.
136
+
137
+ ---
138
+
139
+ ## Future Improvements
140
+ - **PostgreSQL / MySQL Connectors**: Run natural language analytics on enterprise production databases.
141
+ - **Multi-Table JOIN Support**: Incorporate relational schema graphs to query complex multi-table relationships.
142
+ - **Dashboard View**: Pin multiple query results cards and auto-refresh reports.
143
+ - **Interactive Visualizations**: Modify chart styles, axes, and colors using conversational commands.
144
+ - **PDF Exporter**: Export the full history log along with SVG charts and text insights into a PDF report document.
145
+
146
+ ## License
147
+ MIT
backend/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Backend package
backend/config.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from dotenv import load_dotenv
3
+
4
+ # Base directory is the root of the project (parent of 'backend')
5
+ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
6
+
7
+ # Load .env file from base directory
8
+ env_path = os.path.join(BASE_DIR, ".env")
9
+ load_dotenv(env_path)
10
+
11
+ GROQ_API_KEY = os.getenv("GROQ_API_KEY")
12
+ GROQ_MODEL = "llama-3.1-8b-instant"
13
+
14
+ DB_DIR = os.path.join(BASE_DIR, "databases", "csv")
15
+ SCHEMA_DB_DIR = os.path.join(BASE_DIR, "databases", "schema")
16
+ SAMPLE_DATA_DIR = os.path.join(BASE_DIR, "sample_data")
17
+
18
+ MAX_ROWS_RETURNED = 500
19
+ MAX_SQL_RETRIES = 3
20
+
21
+ # Auto-create directories if missing
22
+ os.makedirs(DB_DIR, exist_ok=True)
23
+ os.makedirs(SCHEMA_DB_DIR, exist_ok=True)
24
+ os.makedirs(SAMPLE_DATA_DIR, exist_ok=True)
25
+
26
+ print(f"[CONFIG] Base Directory: {BASE_DIR}")
27
+ print(f"[CONFIG] CSV Database Directory: {DB_DIR}")
28
+ print(f"[CONFIG] Schema Database Directory: {SCHEMA_DB_DIR}")
29
+ print(f"[CONFIG] Sample Data Directory: {SAMPLE_DATA_DIR}")
30
+ print(f"[CONFIG] Groq API Key loaded: {'Yes' if GROQ_API_KEY else 'No'}")
31
+
backend/main.py ADDED
@@ -0,0 +1,754 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import time
4
+ from datetime import datetime
5
+ from collections import defaultdict
6
+ from fastapi import FastAPI, HTTPException, UploadFile, File, Response
7
+ from fastapi.middleware.cors import CORSMiddleware
8
+ from typing import List, Dict, Any
9
+
10
+ from backend import config
11
+ from backend.services import csv_service, sql_service, llm_service, insight_service
12
+ from backend.services.schema_service import schema_service
13
+ from backend.schemas import (
14
+ DatasetsResponse, DatasetInfo, DatasetSchemaResponse, ColumnDetail,
15
+ UploadResponse, DeleteResponse, QueryRequest, QueryResponse, SQLResults,
16
+ HealthResponse, SuggestionsResponse, QueryHistoryResponse, QueryHistoryItem,
17
+ SchemaDatasetsResponse, SchemaUploadResponse, SchemaInfoResponse,
18
+ SchemaPreviewsResponse, SchemaQueryResponse, SchemaQueryRequest
19
+ )
20
+
21
+
22
+ # Global in-memory caches
23
+ query_histories = defaultdict(list) # db_name -> list of dicts
24
+ suggestions_cache = {} # db_name -> list of strings
25
+
26
+ app = FastAPI(
27
+ title="Natural Language Data Analyst API",
28
+ description="Backend API for Natural Language Data Analyst application",
29
+ version="0.1.0"
30
+ )
31
+
32
+ # Enforce CORS allowing all origins
33
+ app.add_middleware(
34
+ CORSMiddleware,
35
+ allow_origins=["*"],
36
+ allow_credentials=True,
37
+ allow_methods=["*"],
38
+ allow_headers=["*"],
39
+ expose_headers=["X-Process-Time"] # Make sure the frontend can read this header
40
+ )
41
+
42
+ def validate_db_name(db_name: str):
43
+ """
44
+ Validates that db_name only contains alphanumeric characters and underscores.
45
+ Prevents path traversal and injection.
46
+ """
47
+ if not re.match(r"^[a-zA-Z0-9_]+$", db_name):
48
+ raise HTTPException(
49
+ status_code=400,
50
+ detail="db_name must only contain alphanumeric characters and underscores."
51
+ )
52
+
53
+ # 1. GET /datasets
54
+ @app.get("/datasets", response_model=DatasetsResponse)
55
+ def get_datasets():
56
+ print("[API] GET /datasets called")
57
+ try:
58
+ # Load sample datasets first
59
+ samples = csv_service.get_sample_datasets()
60
+ sample_names = {ds["name"] for ds in samples}
61
+
62
+ datasets_list = []
63
+ # Append sample datasets mapped to output schema
64
+ for ds in samples:
65
+ datasets_list.append(
66
+ DatasetInfo(
67
+ db_name=ds["name"],
68
+ display_name=ds["display_name"],
69
+ description=ds["description"],
70
+ columns=[col["name"] for col in ds["columns"]],
71
+ row_count=ds["row_count"],
72
+ is_sample=True
73
+ )
74
+ )
75
+
76
+ # Scan databases/ directory for any user uploaded ones
77
+ if os.path.exists(config.DB_DIR):
78
+ for filename in os.listdir(config.DB_DIR):
79
+ if filename.endswith(".db"):
80
+ db_name = os.path.splitext(filename)[0]
81
+ if db_name not in sample_names:
82
+ try:
83
+ schema = csv_service.get_table_schema(db_name)
84
+ datasets_list.append(
85
+ DatasetInfo(
86
+ db_name=db_name,
87
+ display_name=db_name.replace("_", " ").capitalize(),
88
+ description=f"User-uploaded dataset from {db_name}.csv",
89
+ columns=[col["name"] for col in schema["columns"]],
90
+ row_count=schema["row_count"],
91
+ is_sample=False
92
+ )
93
+ )
94
+ except Exception as e:
95
+ print(f"[API] Error reading user DB '{db_name}': {e}")
96
+
97
+ return DatasetsResponse(datasets=datasets_list, total=len(datasets_list))
98
+ except Exception as e:
99
+ print(f"[API] Error in GET /datasets: {e}")
100
+ raise HTTPException(status_code=500, detail=f"Failed to fetch datasets: {str(e)}")
101
+
102
+ # 2. GET /datasets/{db_name}/schema
103
+ @app.get("/datasets/{db_name}/schema", response_model=DatasetSchemaResponse)
104
+ def get_dataset_schema(db_name: str):
105
+ print(f"[API] GET /datasets/{db_name}/schema called")
106
+ validate_db_name(db_name)
107
+
108
+ db_path = os.path.join(config.DB_DIR, f"{db_name}.db")
109
+ if not os.path.exists(db_path):
110
+ raise HTTPException(status_code=404, detail="Dataset not found.")
111
+
112
+ try:
113
+ schema = csv_service.get_table_schema(db_name)
114
+ return DatasetSchemaResponse(
115
+ db_name=db_name,
116
+ table_name=schema["table_name"],
117
+ columns=[
118
+ ColumnDetail(
119
+ name=col["name"],
120
+ type=col["type"],
121
+ sample_values=col["sample_values"]
122
+ ) for col in schema["columns"]
123
+ ],
124
+ row_count=schema["row_count"]
125
+ )
126
+ except Exception as e:
127
+ print(f"[API] Error fetching schema for '{db_name}': {e}")
128
+ raise HTTPException(status_code=500, detail=str(e))
129
+
130
+ # 3. POST /upload
131
+ @app.post("/upload", response_model=UploadResponse)
132
+ def upload_csv(file: UploadFile = File(...)):
133
+ print(f"[API] POST /upload called with file: {file.filename}")
134
+ if not file.filename.endswith(".csv"):
135
+ raise HTTPException(status_code=400, detail="Only .csv files are allowed.")
136
+
137
+ # Sanitize file name to produce db_name
138
+ base_name = os.path.splitext(file.filename)[0].lower().replace(" ", "_")
139
+ db_name = re.sub(r"[^a-zA-Z0-9_]", "", base_name)
140
+ if not db_name:
141
+ raise HTTPException(status_code=400, detail="Invalid filename characters.")
142
+
143
+ db_path = os.path.join(config.DB_DIR, f"{db_name}.db")
144
+ if os.path.exists(db_path):
145
+ raise HTTPException(status_code=409, detail="Dataset already uploaded. Delete it first.")
146
+
147
+ # Save to temp location in DB_DIR
148
+ temp_path = os.path.join(config.DB_DIR, f"temp_{db_name}.csv")
149
+ try:
150
+ with open(temp_path, "wb") as buffer:
151
+ buffer.write(file.file.read())
152
+
153
+ # Load CSV to SQLite database
154
+ schema_info = csv_service.load_csv_to_sqlite(temp_path, db_name)
155
+
156
+ display_name = db_name.replace("_", " ").capitalize()
157
+ columns = [col["name"] for col in schema_info["columns"]]
158
+
159
+ return UploadResponse(
160
+ success=True,
161
+ db_name=db_name,
162
+ display_name=display_name,
163
+ columns=columns,
164
+ row_count=schema_info["row_count"],
165
+ message=f"Dataset '{display_name}' uploaded and processed successfully."
166
+ )
167
+ except Exception as e:
168
+ print(f"[API] Error uploading CSV: {e}")
169
+ # If database was created but failed load, clean it up
170
+ if os.path.exists(db_path):
171
+ try:
172
+ os.remove(db_path)
173
+ except Exception:
174
+ pass
175
+ raise HTTPException(status_code=500, detail=f"Failed to process CSV file: {str(e)}")
176
+ finally:
177
+ # Cleanup temporary CSV file
178
+ if os.path.exists(temp_path):
179
+ try:
180
+ os.remove(temp_path)
181
+ except Exception as e:
182
+ print(f"[API] Failed to delete temp file '{temp_path}': {e}")
183
+
184
+ # 4. DELETE /datasets/{db_name}
185
+ @app.delete("/datasets/{db_name}", response_model=DeleteResponse)
186
+ def delete_dataset(db_name: str):
187
+ print(f"[API] DELETE /datasets/{db_name} called")
188
+ validate_db_name(db_name)
189
+
190
+ if db_name in ["sales", "employees", "ecommerce"]:
191
+ raise HTTPException(status_code=403, detail="Cannot delete sample datasets.")
192
+
193
+ db_path = os.path.join(config.DB_DIR, f"{db_name}.db")
194
+ if not os.path.exists(db_path):
195
+ raise HTTPException(status_code=404, detail="Dataset not found.")
196
+
197
+ try:
198
+ os.remove(db_path)
199
+ return DeleteResponse(
200
+ success=True,
201
+ message=f"Dataset '{db_name}' deleted successfully."
202
+ )
203
+ except Exception as e:
204
+ print(f"[API] Error deleting dataset '{db_name}': {e}")
205
+ raise HTTPException(status_code=500, detail=f"Failed to delete dataset: {str(e)}")
206
+
207
+ # 5. POST /query
208
+ @app.post("/query", response_model=QueryResponse)
209
+ def run_query(request: QueryRequest, response: Response):
210
+ print(f"[API] POST /query called for db: {request.db_name}, question: '{request.question}'")
211
+ start_time = time.perf_counter()
212
+
213
+ db_path = os.path.join(config.DB_DIR, f"{request.db_name}.db")
214
+ if not os.path.exists(db_path):
215
+ raise HTTPException(status_code=404, detail=f"Database '{request.db_name}' not found.")
216
+
217
+ try:
218
+ # a. Get schema
219
+ schema = csv_service.get_table_schema(request.db_name)
220
+
221
+ # b. Generate SQL
222
+ generation = llm_service.generate_sql(request.question, schema)
223
+ sql = generation["sql"]
224
+
225
+ # c. Validate SQL
226
+ if not sql_service.validate_sql(sql):
227
+ raise HTTPException(
228
+ status_code=400,
229
+ detail="Dangerous or invalid SQL query generated. Only SELECT operations are allowed."
230
+ )
231
+
232
+ # d. Execute SQL with auto-retry
233
+ retries = 0
234
+ results = None
235
+ current_sql = sql
236
+ last_error = ""
237
+
238
+ while True:
239
+ try:
240
+ results = sql_service.execute_query(request.db_name, current_sql)
241
+ # Success, break loop
242
+ break
243
+ except Exception as e:
244
+ last_error = str(e)
245
+ print(f"[API] Query execution failed: {last_error}")
246
+
247
+ if retries < config.MAX_SQL_RETRIES:
248
+ retries += 1
249
+ print(f"[API] Retrying query fix (Attempt {retries}/{config.MAX_SQL_RETRIES})...")
250
+ try:
251
+ fix_result = llm_service.fix_sql(current_sql, last_error, schema)
252
+ fixed_sql = fix_result["sql"]
253
+
254
+ # Validate fixed SQL
255
+ if not sql_service.validate_sql(fixed_sql):
256
+ raise ValueError("Fixed SQL failed validation check.")
257
+
258
+ current_sql = fixed_sql
259
+ except Exception as fix_err:
260
+ print(f"[API] Failed to generate/validate fix: {fix_err}")
261
+ # Keep the loop going, next attempts might fix it or we exit with retries exceeded
262
+ else:
263
+ # Retries exceeded
264
+ print("[API] Maximum SQL retries exceeded. Aborting.")
265
+ raise HTTPException(
266
+ status_code=422,
267
+ detail=f"SQL execution failed after {config.MAX_SQL_RETRIES} retries. Last error: {last_error}"
268
+ )
269
+
270
+ # e. Generate insight
271
+ insight = insight_service.generate_insight(request.question, current_sql, results)
272
+
273
+ # f. Calculate processing time and inject header
274
+ process_time_ms = (time.perf_counter() - start_time) * 1000.0
275
+ response.headers["X-Process-Time"] = f"{process_time_ms:.2f}ms"
276
+ print(f"[API] Request processed in {process_time_ms:.2f}ms. Header added.")
277
+
278
+ # Save to query history
279
+ query_histories[request.db_name].append({
280
+ "question": request.question,
281
+ "sql": current_sql,
282
+ "insight": insight,
283
+ "timestamp": datetime.now().isoformat(),
284
+ "row_count": results["row_count"]
285
+ })
286
+ if len(query_histories[request.db_name]) > 20:
287
+ query_histories[request.db_name] = query_histories[request.db_name][-20:]
288
+
289
+ return QueryResponse(
290
+ question=request.question,
291
+ sql=current_sql,
292
+ results=SQLResults(
293
+ columns=results["columns"],
294
+ rows=results["rows"],
295
+ row_count=results["row_count"],
296
+ execution_time_ms=results["execution_time_ms"]
297
+ ),
298
+ insight=insight,
299
+ retries=retries,
300
+ db_name=request.db_name
301
+ )
302
+
303
+ except HTTPException as he:
304
+ raise he
305
+ except Exception as e:
306
+ print(f"[API] Internal error in /query pipeline: {e}")
307
+ raise HTTPException(status_code=500, detail=f"Internal pipeline error: {str(e)}")
308
+
309
+ # 6. GET /datasets/{db_name}/preview
310
+ @app.get("/datasets/{db_name}/preview", response_model=SQLResults)
311
+ def preview_dataset(db_name: str):
312
+ print(f"[API] GET /datasets/{db_name}/preview called")
313
+ validate_db_name(db_name)
314
+
315
+ db_path = os.path.join(config.DB_DIR, f"{db_name}.db")
316
+ if not os.path.exists(db_path):
317
+ raise HTTPException(status_code=404, detail="Dataset not found.")
318
+
319
+ try:
320
+ preview_sql = "SELECT * FROM data LIMIT 10"
321
+ results = sql_service.execute_query(db_name, preview_sql)
322
+ return SQLResults(
323
+ columns=results["columns"],
324
+ rows=results["rows"],
325
+ row_count=results["row_count"],
326
+ execution_time_ms=results["execution_time_ms"]
327
+ )
328
+ except Exception as e:
329
+ print(f"[API] Error in preview for '{db_name}': {e}")
330
+ raise HTTPException(status_code=500, detail=f"Failed to generate preview: {str(e)}")
331
+
332
+ # 7. GET /health
333
+ @app.get("/health", response_model=HealthResponse)
334
+ def health_check():
335
+ print("[API] GET /health called")
336
+ groq_configured = bool(config.GROQ_API_KEY)
337
+
338
+ db_dir_accessible = False
339
+ datasets_loaded = 0
340
+ if os.path.exists(config.DB_DIR):
341
+ db_dir_accessible = os.access(config.DB_DIR, os.R_OK | os.W_OK)
342
+ try:
343
+ datasets_loaded = len([f for f in os.listdir(config.DB_DIR) if f.endswith(".db")])
344
+ except Exception:
345
+ pass
346
+
347
+ status = "healthy" if (groq_configured and db_dir_accessible) else "unhealthy"
348
+
349
+ return HealthResponse(
350
+ status=status,
351
+ groq_configured=groq_configured,
352
+ datasets_loaded=datasets_loaded,
353
+ sample_datasets=["sales", "employees", "ecommerce"]
354
+ )
355
+
356
+ # 8. GET /datasets/{db_name}/suggestions
357
+ @app.get("/datasets/{db_name}/suggestions", response_model=SuggestionsResponse)
358
+ def get_dataset_suggestions(db_name: str):
359
+ print(f"[API] GET /datasets/{db_name}/suggestions called")
360
+ validate_db_name(db_name)
361
+
362
+ db_path = os.path.join(config.DB_DIR, f"{db_name}.db")
363
+ if not os.path.exists(db_path):
364
+ raise HTTPException(status_code=404, detail="Dataset not found.")
365
+
366
+ # Check cache first
367
+ if db_name in suggestions_cache:
368
+ print(f"[API] Suggestions cache hit for '{db_name}'")
369
+ return SuggestionsResponse(db_name=db_name, suggestions=suggestions_cache[db_name])
370
+
371
+ try:
372
+ # Retrieve table schema
373
+ schema = csv_service.get_table_schema(db_name)
374
+ # Generate question suggestions
375
+ suggestions = llm_service.generate_question_suggestions(schema)
376
+ # Store in cache
377
+ suggestions_cache[db_name] = suggestions
378
+
379
+ return SuggestionsResponse(db_name=db_name, suggestions=suggestions)
380
+ except Exception as e:
381
+ print(f"[API] Error generating suggestions for '{db_name}': {e}")
382
+ raise HTTPException(status_code=500, detail=f"Failed to generate suggestions: {str(e)}")
383
+
384
+ # 9. GET /query/history
385
+ @app.get("/query/history", response_model=QueryHistoryResponse)
386
+ def get_query_history(db_name: str):
387
+ print(f"[API] GET /query/history called for '{db_name}'")
388
+ validate_db_name(db_name)
389
+
390
+ db_path = os.path.join(config.DB_DIR, f"{db_name}.db")
391
+ is_schema = False
392
+ if not os.path.exists(db_path):
393
+ schema_db_path = os.path.join(config.SCHEMA_DB_DIR, f"{db_name}.db")
394
+ if os.path.exists(schema_db_path):
395
+ is_schema = True
396
+ db_path = schema_db_path
397
+
398
+ if not os.path.exists(db_path):
399
+ raise HTTPException(status_code=404, detail="Dataset not found.")
400
+
401
+ history_key = f"schema_{db_name}" if is_schema else db_name
402
+ history_list = query_histories.get(history_key, [])
403
+ # Map raw history dict items to QueryHistoryItem
404
+ items = [
405
+ QueryHistoryItem(
406
+ question=h["question"],
407
+ sql=h["sql"],
408
+ insight=h["insight"],
409
+ timestamp=h["timestamp"],
410
+ row_count=h["row_count"]
411
+ ) for h in history_list
412
+ ]
413
+ # Return in reverse chronological order (latest queries first)
414
+ items.reverse()
415
+
416
+ return QueryHistoryResponse(
417
+ db_name=db_name,
418
+ history=items,
419
+ total=len(items)
420
+ )
421
+
422
+ # 10. GET /schema-datasets
423
+ @app.get("/schema-datasets", response_model=SchemaDatasetsResponse)
424
+ def get_schema_datasets():
425
+ print("[API] GET /schema-datasets called")
426
+ try:
427
+ datasets_list = schema_service.get_all_schema_datasets()
428
+ return SchemaDatasetsResponse(datasets=datasets_list, total=len(datasets_list))
429
+ except Exception as e:
430
+ print(f"[API] Error in GET /schema-datasets: {e}")
431
+ raise HTTPException(status_code=500, detail=str(e))
432
+
433
+ # 11. POST /upload/schema
434
+ @app.post("/upload/schema", response_model=SchemaUploadResponse)
435
+ def upload_schema_db(
436
+ schema_file: UploadFile = File(None),
437
+ db_file: UploadFile = File(None),
438
+ erd_image: UploadFile = File(None)
439
+ ):
440
+ schema_fn = schema_file.filename if schema_file else "None"
441
+ db_fn = db_file.filename if db_file else "None"
442
+ erd_fn = erd_image.filename if erd_image else "None"
443
+ print(f"[API] POST /upload/schema called with schema: {schema_fn}, db: {db_fn}, erd_image: {erd_fn}")
444
+
445
+ if not schema_file and not erd_image:
446
+ raise HTTPException(
447
+ status_code=400,
448
+ detail="Please provide either a .sql schema file or an ERD diagram image"
449
+ )
450
+
451
+ if schema_file and not schema_file.filename.endswith(".sql"):
452
+ raise HTTPException(status_code=400, detail="Schema file must have a .sql extension.")
453
+
454
+ if erd_image:
455
+ ext = os.path.splitext(erd_image.filename)[1].lower()
456
+ if ext not in (".png", ".jpg", ".jpeg", ".pdf"):
457
+ raise HTTPException(
458
+ status_code=400,
459
+ detail="ERD image must be a .png, .jpg, .jpeg, or .pdf file."
460
+ )
461
+
462
+ if db_file and not db_file.filename.endswith((".db", ".sqlite")):
463
+ raise HTTPException(status_code=400, detail="Database file must have a .db or .sqlite extension.")
464
+
465
+ try:
466
+ # Determine db_name from the uploaded db file or fall back to the schema file name
467
+ if db_file:
468
+ base_name = os.path.splitext(db_file.filename)[0].lower().replace(" ", "_")
469
+ elif schema_file:
470
+ base_name = os.path.splitext(schema_file.filename)[0].lower().replace(" ", "_")
471
+ # Strip common suffixes like _schema, _ddl
472
+ for suffix in ["_schema", "_ddl", "_create", "_tables"]:
473
+ if base_name.endswith(suffix):
474
+ base_name = base_name[:-len(suffix)]
475
+ break
476
+ else:
477
+ base_name = "uploaded_db"
478
+ db_name = re.sub(r"[^a-zA-Z0-9_]", "", base_name)
479
+ if not db_name:
480
+ raise HTTPException(status_code=400, detail="Invalid filename characters.")
481
+
482
+ db_dest_path = os.path.join(config.SCHEMA_DB_DIR, f"{db_name}.db")
483
+ if os.path.exists(db_dest_path):
484
+ raise HTTPException(status_code=409, detail="Database dataset already uploaded. Delete it first.")
485
+
486
+ # Save uploaded db to a temporary file (if provided)
487
+ temp_db_path = None
488
+ if db_file:
489
+ temp_db_path = os.path.join(config.SCHEMA_DB_DIR, f"temp_{db_name}.db")
490
+ with open(temp_db_path, "wb") as buffer:
491
+ buffer.write(db_file.file.read())
492
+
493
+ # Optional sql content
494
+ schema_content = None
495
+ if schema_file:
496
+ schema_content = schema_file.file.read().decode("utf-8")
497
+
498
+ # Optional image path
499
+ temp_img_path = None
500
+ if erd_image:
501
+ ext = os.path.splitext(erd_image.filename)[1].lower()
502
+ temp_img_path = os.path.join(config.SCHEMA_DB_DIR, f"temp_{db_name}{ext}")
503
+ with open(temp_img_path, "wb") as buffer:
504
+ buffer.write(erd_image.file.read())
505
+
506
+ # Register schema db (uploaded_db_path may be None if building from SQL)
507
+ info = schema_service.register_schema_db(
508
+ db_name=db_name,
509
+ schema_sql_content=schema_content,
510
+ uploaded_db_path=temp_db_path,
511
+ erd_image_path=temp_img_path
512
+ )
513
+
514
+ # Validation check: at least 2 tables detected after cleaning
515
+ if info["total_tables"] < 2:
516
+ try:
517
+ db_path = os.path.join(config.SCHEMA_DB_DIR, f"{db_name}.db")
518
+ sql_path = os.path.join(config.SCHEMA_DB_DIR, f"{db_name}.sql")
519
+ json_path = os.path.join(config.SCHEMA_DB_DIR, f"{db_name}.json")
520
+ for p in [db_path, sql_path, json_path]:
521
+ if os.path.exists(p):
522
+ os.remove(p)
523
+ except Exception:
524
+ pass
525
+ raise HTTPException(
526
+ status_code=400,
527
+ detail="Validation failed: At least 2 tables must be detected in the schema."
528
+ )
529
+
530
+ # Clean up temp db and image
531
+ if temp_db_path and os.path.exists(temp_db_path):
532
+ try:
533
+ os.remove(temp_db_path)
534
+ except Exception:
535
+ pass
536
+ if temp_img_path and os.path.exists(temp_img_path):
537
+ try:
538
+ os.remove(temp_img_path)
539
+ except Exception:
540
+ pass
541
+
542
+ return SchemaUploadResponse(
543
+ success=True,
544
+ db_name=db_name,
545
+ total_tables=info["total_tables"],
546
+ total_rows=info["total_rows"],
547
+ tables=[t["name"] for t in info["tables"]],
548
+ relationships=info["relationships"],
549
+ message="Schema database registered successfully"
550
+ )
551
+ except HTTPException as he:
552
+ raise he
553
+ except Exception as e:
554
+ print(f"[API] Error in POST /upload/schema: {e}")
555
+ # Clean up temp files on error
556
+ if 'temp_db_path' in locals() and temp_db_path and os.path.exists(temp_db_path):
557
+ try:
558
+ os.remove(temp_db_path)
559
+ except Exception:
560
+ pass
561
+ if 'temp_img_path' in locals() and temp_img_path and os.path.exists(temp_img_path):
562
+ try:
563
+ os.remove(temp_img_path)
564
+ except Exception:
565
+ pass
566
+ raise HTTPException(status_code=500, detail=str(e))
567
+
568
+ # 12. GET /schema-datasets/{db_name}/schema
569
+ @app.get("/schema-datasets/{db_name}/schema", response_model=SchemaInfoResponse)
570
+ def get_schema_details(db_name: str):
571
+ print(f"[API] GET /schema-datasets/{db_name}/schema called")
572
+ validate_db_name(db_name)
573
+
574
+ try:
575
+ info = schema_service.get_schema_db_info(db_name)
576
+ return SchemaInfoResponse(
577
+ db_name=db_name,
578
+ tables=info["tables"],
579
+ relationships=info["relationships"],
580
+ total_tables=info["total_tables"],
581
+ total_rows=info["total_rows"]
582
+ )
583
+ except FileNotFoundError:
584
+ raise HTTPException(status_code=404, detail="Schema dataset not found.")
585
+ except Exception as e:
586
+ print(f"[API] Error in GET schema details for '{db_name}': {e}")
587
+ raise HTTPException(status_code=500, detail=str(e))
588
+
589
+ # 13. GET /schema-datasets/{db_name}/preview
590
+ @app.get("/schema-datasets/{db_name}/preview", response_model=SchemaPreviewsResponse)
591
+ def get_schema_preview(db_name: str):
592
+ print(f"[API] GET /schema-datasets/{db_name}/preview called")
593
+ validate_db_name(db_name)
594
+
595
+ try:
596
+ info = schema_service.get_schema_db_info(db_name)
597
+ previews = []
598
+
599
+ for tbl in info["tables"]:
600
+ tbl_name = tbl["name"]
601
+ preview_sql = f"SELECT * FROM {tbl_name} LIMIT 5"
602
+ results = sql_service.execute_query(db_name, preview_sql, mode="schema")
603
+ previews.append({
604
+ "table_name": tbl_name,
605
+ "columns": results["columns"],
606
+ "rows": results["rows"],
607
+ "total_rows": tbl["row_count"]
608
+ })
609
+
610
+ return SchemaPreviewsResponse(db_name=db_name, previews=previews)
611
+ except FileNotFoundError:
612
+ raise HTTPException(status_code=404, detail="Schema dataset not found.")
613
+ except Exception as e:
614
+ print(f"[API] Error in GET schema preview for '{db_name}': {e}")
615
+ raise HTTPException(status_code=500, detail=str(e))
616
+
617
+ # 14. POST /schema-query
618
+ @app.post("/schema-query", response_model=SchemaQueryResponse)
619
+ def run_schema_query(request: SchemaQueryRequest, response: Response):
620
+ print(f"[API] POST /schema-query called for db: {request.db_name}, question: '{request.question}'")
621
+ start_time = time.perf_counter()
622
+
623
+ db_path = os.path.join(config.SCHEMA_DB_DIR, f"{request.db_name}.db")
624
+ if not os.path.exists(db_path):
625
+ raise HTTPException(status_code=404, detail=f"Database '{request.db_name}' not found.")
626
+
627
+ try:
628
+ # a. Get schema info
629
+ schema_info = schema_service.get_schema_db_info(request.db_name)
630
+ known_tables = [t["name"] for t in schema_info["tables"]]
631
+
632
+ # b. Generate SQL
633
+ generation = llm_service.generate_sql_schema(request.question, schema_info)
634
+ sql = generation["sql"]
635
+
636
+ # c. Validate SQL
637
+ if not sql_service.validate_sql(sql):
638
+ raise HTTPException(
639
+ status_code=400,
640
+ detail="Dangerous or invalid SQL query generated. Only SELECT operations are allowed."
641
+ )
642
+
643
+ # d. Execute query with auto-retry
644
+ retries = 0
645
+ results = None
646
+ current_sql = sql
647
+ last_error = ""
648
+
649
+ while True:
650
+ try:
651
+ results = sql_service.execute_query(request.db_name, current_sql, mode="schema")
652
+ break
653
+ except Exception as e:
654
+ last_error = str(e)
655
+ print(f"[API] Schema Query execution failed: {last_error}")
656
+
657
+ if retries < config.MAX_SQL_RETRIES:
658
+ retries += 1
659
+ print(f"[API] Retrying query fix (Attempt {retries}/{config.MAX_SQL_RETRIES})...")
660
+ try:
661
+ fix_result = llm_service.fix_sql_schema(current_sql, last_error, schema_info)
662
+ fixed_sql = fix_result["sql"]
663
+
664
+ if not sql_service.validate_sql(fixed_sql):
665
+ raise ValueError("Fixed SQL failed validation check.")
666
+ current_sql = fixed_sql
667
+ except Exception as fix_err:
668
+ print(f"[API] Failed to generate/validate fix: {fix_err}")
669
+ else:
670
+ print("[API] Maximum SQL retries exceeded in schema mode. Aborting.")
671
+ raise HTTPException(
672
+ status_code=422,
673
+ detail=f"SQL execution failed after {config.MAX_SQL_RETRIES} retries. Last error: {last_error}"
674
+ )
675
+
676
+ # e. Generate insight
677
+ insight = insight_service.generate_insight(request.question, current_sql, results)
678
+
679
+ # f. Process time header
680
+ process_time_ms = (time.perf_counter() - start_time) * 1000.0
681
+ response.headers["X-Process-Time"] = f"{process_time_ms:.2f}ms"
682
+
683
+ # g. Parse tables_used
684
+ matches = re.findall(r"\b(?:FROM|JOIN)\s+([a-zA-Z0-9_]+)", current_sql, re.IGNORECASE)
685
+ tables_used = []
686
+ for m in matches:
687
+ tbl = m.lower().strip()
688
+ if tbl in known_tables and tbl not in tables_used:
689
+ tables_used.append(tbl)
690
+
691
+ # Save to query history
692
+ query_histories[f"schema_{request.db_name}"].append({
693
+ "question": request.question,
694
+ "sql": current_sql,
695
+ "insight": insight,
696
+ "timestamp": datetime.now().isoformat(),
697
+ "row_count": results["row_count"]
698
+ })
699
+ if len(query_histories[f"schema_{request.db_name}"]) > 20:
700
+ query_histories[f"schema_{request.db_name}"] = query_histories[f"schema_{request.db_name}"][-20:]
701
+
702
+ return SchemaQueryResponse(
703
+ question=request.question,
704
+ sql=current_sql,
705
+ results=SQLResults(
706
+ columns=results["columns"],
707
+ rows=results["rows"],
708
+ row_count=results["row_count"],
709
+ execution_time_ms=results["execution_time_ms"]
710
+ ),
711
+ insight=insight,
712
+ tables_used=tables_used,
713
+ retries=retries,
714
+ db_name=request.db_name
715
+ )
716
+ except HTTPException as he:
717
+ raise he
718
+ except Exception as e:
719
+ print(f"[API] Error in run_schema_query: {e}")
720
+ raise HTTPException(status_code=500, detail=str(e))
721
+
722
+ # 15. DELETE /schema-datasets/{db_name}
723
+ @app.delete("/schema-datasets/{db_name}", response_model=DeleteResponse)
724
+ def delete_schema_dataset(db_name: str):
725
+ print(f"[API] DELETE /schema-datasets/{db_name} called")
726
+ validate_db_name(db_name)
727
+
728
+ if db_name == "ecommerce":
729
+ raise HTTPException(status_code=403, detail="Cannot delete sample datasets.")
730
+
731
+ db_path = os.path.join(config.SCHEMA_DB_DIR, f"{db_name}.db")
732
+ sql_path = os.path.join(config.SCHEMA_DB_DIR, f"{db_name}.sql")
733
+
734
+ if not os.path.exists(db_path) or not os.path.exists(sql_path):
735
+ raise HTTPException(status_code=404, detail="Schema dataset not found.")
736
+
737
+ try:
738
+ os.remove(db_path)
739
+ os.remove(sql_path)
740
+ json_path = os.path.join(config.SCHEMA_DB_DIR, f"{db_name}.json")
741
+ if os.path.exists(json_path):
742
+ os.remove(json_path)
743
+ return DeleteResponse(
744
+ success=True,
745
+ message=f"Schema dataset '{db_name}' deleted successfully."
746
+ )
747
+ except Exception as e:
748
+ print(f"[API] Error deleting schema dataset '{db_name}': {e}")
749
+ raise HTTPException(status_code=500, detail=str(e))
750
+
751
+ if __name__ == "__main__":
752
+ import uvicorn
753
+ uvicorn.run("main:app", host="127.0.0.1", port=8000, reload=True)
754
+
backend/requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ fastapi==0.137.1
2
+ uvicorn==0.49.0
3
+ python-multipart==0.0.32
4
+ pandas==3.0.3
5
+ groq==1.4.0
6
+ python-dotenv==1.2.2
7
+ pydantic==2.13.4
backend/schemas.py ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ from pydantic import BaseModel, Field, field_validator
3
+ from typing import List, Any
4
+
5
+ # 1. Models for GET /datasets
6
+ class DatasetInfo(BaseModel):
7
+ db_name: str
8
+ display_name: str
9
+ description: str
10
+ columns: List[str]
11
+ row_count: int
12
+ is_sample: bool
13
+
14
+ class DatasetsResponse(BaseModel):
15
+ datasets: List[DatasetInfo]
16
+ total: int
17
+
18
+ # 2. Models for GET /datasets/{db_name}/schema
19
+ class ColumnDetail(BaseModel):
20
+ name: str
21
+ type: str
22
+ sample_values: List[Any]
23
+
24
+ class DatasetSchemaResponse(BaseModel):
25
+ db_name: str
26
+ table_name: str
27
+ columns: List[ColumnDetail]
28
+ row_count: int
29
+
30
+ # 3. Model for POST /upload
31
+ class UploadResponse(BaseModel):
32
+ success: bool
33
+ db_name: str
34
+ display_name: str
35
+ columns: List[str]
36
+ row_count: int
37
+ message: str
38
+
39
+ # 4. Model for DELETE /datasets/{db_name}
40
+ class DeleteResponse(BaseModel):
41
+ success: bool
42
+ message: str
43
+
44
+ # 5. Models for POST /query
45
+ class SQLResults(BaseModel):
46
+ columns: List[str]
47
+ rows: List[List[Any]]
48
+ row_count: int
49
+ execution_time_ms: float
50
+
51
+ class QueryRequest(BaseModel):
52
+ question: str = Field(..., min_length=3, max_length=300)
53
+ db_name: str
54
+
55
+ @field_validator("db_name")
56
+ @classmethod
57
+ def validate_db_name(cls, v: str) -> str:
58
+ # Enforce that db_name must only contain alphanumeric characters and underscores
59
+ if not re.match(r"^[a-zA-Z0-9_]+$", v):
60
+ raise ValueError("db_name must only contain alphanumeric characters and underscores")
61
+ return v
62
+
63
+ class QueryResponse(BaseModel):
64
+ question: str
65
+ sql: str
66
+ results: SQLResults
67
+ insight: str
68
+ retries: int
69
+ db_name: str
70
+
71
+ # 6. Model for GET /health
72
+ class HealthResponse(BaseModel):
73
+ status: str
74
+ groq_configured: bool
75
+ datasets_loaded: int
76
+ sample_datasets: List[str]
77
+
78
+ # Schemas for new Suggestions and History endpoints
79
+ class SuggestionsResponse(BaseModel):
80
+ db_name: str
81
+ suggestions: List[str]
82
+
83
+ class QueryHistoryItem(BaseModel):
84
+ question: str
85
+ sql: str
86
+ insight: str
87
+ timestamp: str
88
+ row_count: int
89
+
90
+ class QueryHistoryResponse(BaseModel):
91
+ db_name: str
92
+ history: List[QueryHistoryItem]
93
+ total: int
94
+
95
+ # 7. Models for Schema/Relational databases
96
+ class SchemaRelationship(BaseModel):
97
+ from_table: str
98
+ from_column: str
99
+ to_table: str
100
+ to_column: str
101
+ cardinality: Any = None
102
+ from_participation: Any = None
103
+ to_participation: Any = None
104
+ relationship_name: Any = None
105
+
106
+ class SchemaDatasetInfo(BaseModel):
107
+ db_name: str
108
+ display_name: str
109
+ description: str
110
+ mode: str = "schema"
111
+ is_sample: bool
112
+ total_tables: int
113
+ total_rows: int
114
+ tables: List[str]
115
+ relationships: List[SchemaRelationship]
116
+
117
+ class SchemaDatasetsResponse(BaseModel):
118
+ datasets: List[SchemaDatasetInfo]
119
+ total: int
120
+
121
+ class SchemaColumnDetail(BaseModel):
122
+ name: str
123
+ type: str
124
+ is_primary_key: bool
125
+ is_foreign_key: bool
126
+ references_table: Any = None
127
+ references_column: Any = None
128
+ sample_values: List[Any] = []
129
+
130
+ class SchemaTableDetail(BaseModel):
131
+ name: str
132
+ columns: List[SchemaColumnDetail]
133
+ row_count: int
134
+
135
+ class SchemaInfoResponse(BaseModel):
136
+ db_name: str
137
+ mode: str = "schema"
138
+ tables: List[SchemaTableDetail]
139
+ relationships: List[SchemaRelationship]
140
+ total_tables: int
141
+ total_rows: int
142
+
143
+ class TablePreviewItem(BaseModel):
144
+ table_name: str
145
+ columns: List[str]
146
+ rows: List[List[Any]]
147
+ total_rows: int
148
+
149
+ class SchemaPreviewsResponse(BaseModel):
150
+ db_name: str
151
+ previews: List[TablePreviewItem]
152
+
153
+ class SchemaQueryRequest(BaseModel):
154
+ question: str = Field(..., min_length=3, max_length=300)
155
+ db_name: str
156
+
157
+ @field_validator("db_name")
158
+ @classmethod
159
+ def validate_db_name(cls, v: str) -> str:
160
+ if not re.match(r"^[a-zA-Z0-9_]+$", v):
161
+ raise ValueError("db_name must only contain alphanumeric characters and underscores")
162
+ return v
163
+
164
+ class SchemaQueryResponse(BaseModel):
165
+ question: str
166
+ sql: str
167
+ mode: str = "schema"
168
+ results: SQLResults
169
+ insight: str
170
+ tables_used: List[str]
171
+ retries: int
172
+ db_name: str
173
+
174
+ class SchemaUploadResponse(BaseModel):
175
+ success: bool
176
+ db_name: str
177
+ mode: str = "schema"
178
+ total_tables: int
179
+ total_rows: int
180
+ tables: List[str]
181
+ relationships: List[SchemaRelationship]
182
+ message: str
183
+
184
+
backend/services/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from backend.services.csv_service import csv_service
2
+ from backend.services.sql_service import sql_service
3
+ from backend.services.llm_service import llm_service
4
+ from backend.services.insight_service import insight_service
5
+
6
+ __all__ = [
7
+ "csv_service",
8
+ "sql_service",
9
+ "llm_service",
10
+ "insight_service",
11
+ ]
backend/services/csv_service.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sqlite3
3
+ import pandas as pd
4
+ from typing import List, Dict, Any
5
+ from backend import config
6
+
7
+ class CSVService:
8
+ def __init__(self):
9
+ print("[CSVService] Initializing CSVService singleton...")
10
+ # Ensure directories exist
11
+ os.makedirs(config.DB_DIR, exist_ok=True)
12
+ os.makedirs(config.SAMPLE_DATA_DIR, exist_ok=True)
13
+
14
+ def _infer_column_type(self, series: pd.Series) -> str:
15
+ """
16
+ Helper method to infer SQLite-compatible column types from a pandas Series.
17
+ """
18
+ try:
19
+ # If dtype is numeric, check if integer or float
20
+ if pd.api.types.is_integer_dtype(series):
21
+ return "INTEGER"
22
+ elif pd.api.types.is_float_dtype(series):
23
+ return "REAL"
24
+
25
+ # Check if it looks like a datetime
26
+ # We try to convert to datetime. If it succeeds without producing more than 10% NaNs (on non-empty series), it's likely a date/time.
27
+ non_null_series = series.dropna()
28
+ if not non_null_series.empty:
29
+ try:
30
+ converted = pd.to_datetime(non_null_series, errors='coerce')
31
+ null_pct = converted.isna().sum() / len(non_null_series)
32
+ if null_pct < 0.1:
33
+ return "DATE"
34
+ except Exception:
35
+ pass
36
+
37
+ return "TEXT"
38
+ except Exception as e:
39
+ print(f"[CSVService] Error inferring type: {e}. Defaulting to TEXT.")
40
+ return "TEXT"
41
+
42
+ def load_csv_to_sqlite(self, file_path: str, db_name: str) -> dict:
43
+ """
44
+ Reads a CSV with pandas, infers column types, creates a SQLite database,
45
+ loads the dataframe into a table named 'data', and returns schema metadata.
46
+ """
47
+ print(f"[CSVService] Loading CSV from {file_path} into database {db_name}.db")
48
+ try:
49
+ # Read CSV
50
+ df = pd.read_csv(file_path)
51
+
52
+ # Remove any leading/trailing whitespaces in string columns and header names
53
+ df.columns = [col.strip() for col in df.columns]
54
+ for col in df.select_dtypes(include=['object']):
55
+ df[col] = df[col].astype(str).str.strip()
56
+
57
+ db_path = os.path.join(config.DB_DIR, f"{db_name}.db")
58
+ conn = sqlite3.connect(db_path)
59
+
60
+ # Load into SQLite table 'data'
61
+ df.to_sql("data", conn, if_exists="replace", index=False)
62
+
63
+ # Determine columns metadata
64
+ columns_metadata = []
65
+ for col in df.columns:
66
+ inferred_type = self._infer_column_type(df[col])
67
+ # Convert numpy values to native Python types for JSON serialization
68
+ sample_vals = df[col].dropna().unique()[:3]
69
+ sample_vals_list = []
70
+ for val in sample_vals:
71
+ if hasattr(val, "item"):
72
+ sample_vals_list.append(val.item())
73
+ else:
74
+ sample_vals_list.append(val)
75
+
76
+ columns_metadata.append({
77
+ "name": col,
78
+ "type": inferred_type,
79
+ "sample_values": sample_vals_list
80
+ })
81
+
82
+ row_count = len(df)
83
+ conn.close()
84
+
85
+ result = {
86
+ "db_name": db_name,
87
+ "table_name": "data",
88
+ "columns": columns_metadata,
89
+ "row_count": row_count
90
+ }
91
+ print(f"[CSVService] Successfully loaded {row_count} rows into {db_name}.db table 'data'")
92
+ return result
93
+
94
+ except Exception as e:
95
+ print(f"[CSVService] Failed to load CSV {file_path} to SQLite: {e}")
96
+ raise ValueError(f"Failed to load CSV to SQLite: {str(e)}")
97
+
98
+ def get_table_schema(self, db_name: str) -> dict:
99
+ """
100
+ Connects to db_name.db and retrieves the table 'data' schema details.
101
+ """
102
+ print(f"[CSVService] Retrieving table schema for {db_name}.db")
103
+ try:
104
+ db_path = os.path.join(config.DB_DIR, f"{db_name}.db")
105
+ if not os.path.exists(db_path):
106
+ raise FileNotFoundError(f"Database {db_name}.db does not exist.")
107
+
108
+ conn = sqlite3.connect(db_path)
109
+ cursor = conn.cursor()
110
+
111
+ # Check row count
112
+ cursor.execute("SELECT COUNT(*) FROM data")
113
+ row_count = cursor.fetchone()[0]
114
+
115
+ # Retrieve table schema info
116
+ cursor.execute("PRAGMA table_info(data)")
117
+ columns_info = cursor.fetchall()
118
+
119
+ columns = []
120
+ for col_info in columns_info:
121
+ col_name = col_info[1]
122
+ col_type = col_info[2]
123
+
124
+ # Fetch up to 3 unique non-null sample values from SQLite
125
+ cursor.execute(f'SELECT DISTINCT "{col_name}" FROM data WHERE "{col_name}" IS NOT NULL LIMIT 3')
126
+ sample_vals = [row[0] for row in cursor.fetchall()]
127
+
128
+ columns.append({
129
+ "name": col_name,
130
+ "type": col_type,
131
+ "sample_values": sample_vals
132
+ })
133
+
134
+ conn.close()
135
+
136
+ schema = {
137
+ "table_name": "data",
138
+ "columns": columns,
139
+ "row_count": row_count
140
+ }
141
+ print(f"[CSVService] Retrieved schema for {db_name}.db with {row_count} rows.")
142
+ return schema
143
+
144
+ except Exception as e:
145
+ print(f"[CSVService] Failed to retrieve table schema for {db_name}: {e}")
146
+ raise ValueError(f"Failed to retrieve table schema: {str(e)}")
147
+
148
+ def get_sample_datasets(self) -> List[dict]:
149
+ """
150
+ Reads all CSVs in sample_data/ folder. Load them to SQLite if not already loaded,
151
+ and returns details of each.
152
+ """
153
+ print("[CSVService] Fetching sample datasets...")
154
+ try:
155
+ datasets = []
156
+ descriptions = {
157
+ "sales": "Monthly sales data across products, regions, and salespeople",
158
+ "employees": "Company HR data with salaries, departments, and performance",
159
+ "ecommerce": "Online store orders with products, customers, and payments"
160
+ }
161
+
162
+ if not os.path.exists(config.SAMPLE_DATA_DIR):
163
+ print(f"[CSVService] Sample data directory {config.SAMPLE_DATA_DIR} does not exist.")
164
+ return []
165
+
166
+ for filename in os.listdir(config.SAMPLE_DATA_DIR):
167
+ if filename.endswith(".csv"):
168
+ name = os.path.splitext(filename)[0]
169
+ file_path = os.path.join(config.SAMPLE_DATA_DIR, filename)
170
+ db_path = os.path.join(config.DB_DIR, f"{name}.db")
171
+
172
+ display_name = name.capitalize()
173
+ description = descriptions.get(name, f"Sample dataset containing {name} records")
174
+
175
+ # If not already loaded to SQLite, load it
176
+ if not os.path.exists(db_path):
177
+ print(f"[CSVService] Pre-loading sample CSV: {filename}")
178
+ schema_info = self.load_csv_to_sqlite(file_path, name)
179
+ else:
180
+ schema_info = self.get_table_schema(name)
181
+
182
+ datasets.append({
183
+ "name": name,
184
+ "display_name": display_name,
185
+ "description": description,
186
+ "columns": schema_info["columns"],
187
+ "row_count": schema_info["row_count"]
188
+ })
189
+
190
+ return datasets
191
+ except Exception as e:
192
+ print(f"[CSVService] Failed to fetch sample datasets: {e}")
193
+ raise ValueError(f"Failed to fetch sample datasets: {str(e)}")
194
+
195
+ # Singleton instance
196
+ csv_service = CSVService()
backend/services/insight_service.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ from groq import Groq
4
+ from typing import Dict, Any
5
+ from backend import config
6
+
7
+ class InsightService:
8
+ def __init__(self):
9
+ print("[InsightService] Initializing InsightService singleton...")
10
+ self.client = None
11
+
12
+ def _get_client(self) -> Groq:
13
+ if self.client is None:
14
+ if not config.GROQ_API_KEY:
15
+ raise ValueError("Groq API Key is not set in the environment or configuration.")
16
+ self.client = Groq(api_key=config.GROQ_API_KEY)
17
+ return self.client
18
+
19
+ def generate_insight(self, question: str, sql: str, results: dict) -> str:
20
+ """
21
+ Generates a 2-3 sentence human insight from the user's question,
22
+ the SQL query executed, and the query results (limiting to first 10 rows).
23
+ """
24
+ print(f"[InsightService] Generating insight for question: '{question}'")
25
+ try:
26
+ client = self._get_client()
27
+
28
+ # 1. Format first 10 rows of results for the prompt
29
+ columns = results.get("columns", [])
30
+ raw_rows = results.get("rows", [])
31
+ first_10_rows = raw_rows[:10]
32
+
33
+ # Map columns to values for a friendly JSON representation
34
+ formatted_rows = []
35
+ for row in first_10_rows:
36
+ formatted_rows.append(dict(zip(columns, row)))
37
+
38
+ results_json_str = json.dumps(formatted_rows, indent=2, default=str)
39
+
40
+ system_prompt = (
41
+ "You are an expert data analyst who explains database results in clear, natural language.\n"
42
+ "Your insights must be factual, directly answering the user's question based ONLY on the provided database results.\n"
43
+ "Provide a concise summary (2-3 sentences) pointing out specific numbers, trends, or comparisons from the results.\n"
44
+ "Do not start with 'Based on the data' or 'The data shows' - just state the findings directly.\n"
45
+ "Do not include any greeting, markdown formatting (other than numbers/currency), or meta-explanation."
46
+ )
47
+
48
+ user_prompt = (
49
+ f"Given this question: '{question}'\n"
50
+ f"And this SQL query: '{sql}'\n"
51
+ f"And these results (first 10 rows):\n"
52
+ f"{results_json_str}\n\n"
53
+ f"Write 2-3 sentences of insight about what the data shows. "
54
+ f"Be specific — mention actual numbers, trends, or comparisons from the results. "
55
+ f"Do not say 'the data shows' — just state the findings directly."
56
+ )
57
+
58
+ print("[InsightService] Sending insight request to Groq...")
59
+ completion = client.chat.completions.create(
60
+ model=config.GROQ_MODEL,
61
+ messages=[
62
+ {"role": "system", "content": system_prompt},
63
+ {"role": "user", "content": user_prompt}
64
+ ],
65
+ temperature=0.3 # slightly higher temperature for smooth natural language generation
66
+ )
67
+
68
+ insight = completion.choices[0].message.content.strip()
69
+ print(f"[InsightService] Generated insight: {insight}")
70
+ return insight
71
+
72
+ except Exception as e:
73
+ print(f"[InsightService] Error generating insight: {e}")
74
+ raise ValueError(f"Failed to generate insight: {str(e)}")
75
+
76
+ # Singleton instance
77
+ insight_service = InsightService()
backend/services/llm_service.py ADDED
@@ -0,0 +1,446 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from groq import Groq
3
+ from typing import Dict, Any
4
+ from backend import config
5
+
6
+ class LLMService:
7
+ def __init__(self):
8
+ print("[LLMService] Initializing LLMService singleton...")
9
+ # Since Groq client requires an API key, we will instantiate it lazily or during init
10
+ # If API key is empty/missing, it will raise an error later when invoked.
11
+ self.client = None
12
+
13
+ def _get_client(self) -> Groq:
14
+ if self.client is None:
15
+ if not config.GROQ_API_KEY:
16
+ raise ValueError("Groq API Key is not set in the environment or configuration.")
17
+ self.client = Groq(api_key=config.GROQ_API_KEY)
18
+ return self.client
19
+
20
+ def _clean_sql_response(self, response: str) -> str:
21
+ """
22
+ Cleans the raw response from the LLM, stripping markdown code blocks,
23
+ backticks, and any leading/trailing whitespace.
24
+ """
25
+ cleaned = response.strip()
26
+
27
+ # Strip code blocks starting with ``` (e.g. ```sql or ```)
28
+ if cleaned.startswith("```"):
29
+ lines = cleaned.split("\n")
30
+ sql_lines = [line for line in lines if not line.strip().startswith("```")]
31
+ cleaned = "\n".join(sql_lines).strip()
32
+
33
+ # Strip single backticks
34
+ cleaned = cleaned.strip("`").strip()
35
+
36
+ # Sometimes LLMs add an ending semicolon, which is fine, but let's keep it clean
37
+ return cleaned
38
+
39
+ def generate_sql(self, question: str, schema: dict) -> Dict[str, Any]:
40
+ """
41
+ Generates SQLite SELECT query based on the user question and database schema.
42
+ """
43
+ print(f"[LLMService] Generating SQL for question: '{question}'")
44
+ try:
45
+ client = self._get_client()
46
+
47
+ # Format the columns section of the prompt
48
+ columns_prompt = []
49
+ for col in schema.get("columns", []):
50
+ name = col.get("name")
51
+ col_type = col.get("type")
52
+ sample_vals = col.get("sample_values", [])
53
+ columns_prompt.append(f"- {name} ({col_type}): examples: {sample_vals}")
54
+
55
+ columns_str = "\n".join(columns_prompt)
56
+
57
+ system_prompt = (
58
+ "You are an expert SQL analyst. Generate SQLite-compatible SELECT queries only.\n"
59
+ "Never use INSERT, UPDATE, DELETE, DROP, or any destructive operations.\n"
60
+ "Always use the exact table name 'data'.\n"
61
+ "For date columns, use SQLite date functions (strftime, date())\n"
62
+ "For aggregations, always include ORDER BY to make results meaningful\n"
63
+ "Limit results to 20 rows maximum unless the question asks for all data\n"
64
+ "Always alias aggregated columns with readable names (e.g. SUM(revenue) AS total_revenue)\n"
65
+ "Return ONLY the SQL query, no explanation, no markdown, no backticks."
66
+ )
67
+
68
+ user_prompt = (
69
+ f"Table schema:\n"
70
+ f"Table name: data\n"
71
+ f"Columns:\n"
72
+ f"{columns_str}\n\n"
73
+ f"Question: {question}\n\n"
74
+ f"Write a SQLite SELECT query to answer this question."
75
+ )
76
+
77
+ print("[LLMService] Sending generation request to Groq...")
78
+ completion = client.chat.completions.create(
79
+ model=config.GROQ_MODEL,
80
+ messages=[
81
+ {"role": "system", "content": system_prompt},
82
+ {"role": "user", "content": user_prompt}
83
+ ],
84
+ temperature=0.0
85
+ )
86
+
87
+ raw_response = completion.choices[0].message.content
88
+ sql = self._clean_sql_response(raw_response)
89
+
90
+ print(f"[LLMService] Generated SQL:\n{sql}")
91
+ return {
92
+ "sql": sql,
93
+ "raw_response": raw_response
94
+ }
95
+
96
+ except Exception as e:
97
+ print(f"[LLMService] Error generating SQL: {e}")
98
+ raise ValueError(f"Failed to generate SQL: {str(e)}")
99
+
100
+ def fix_sql(self, original_sql: str, error: str, schema: dict) -> Dict[str, str]:
101
+ """
102
+ Takes a failed SQL query, the SQLite execution error, and the database schema,
103
+ and requests the LLM to return a corrected query.
104
+ """
105
+ print(f"[LLMService] Fixing SQL query: '{original_sql}' due to error: '{error}'")
106
+ try:
107
+ client = self._get_client()
108
+
109
+ # Format the columns section of the prompt
110
+ columns_prompt = []
111
+ for col in schema.get("columns", []):
112
+ name = col.get("name")
113
+ col_type = col.get("type")
114
+ sample_vals = col.get("sample_values", [])
115
+ columns_prompt.append(f"- {name} ({col_type}): examples: {sample_vals}")
116
+
117
+ columns_str = "\n".join(columns_prompt)
118
+
119
+ system_prompt = (
120
+ "You are an expert SQL analyst. Correct the failing SQL query.\n"
121
+ "Generate SQLite-compatible SELECT queries only.\n"
122
+ "Never use INSERT, UPDATE, DELETE, DROP, or any destructive operations.\n"
123
+ "Always use the exact table name 'data'.\n"
124
+ "Return ONLY the corrected SQL query, no explanation, no markdown, no backticks."
125
+ )
126
+
127
+ user_prompt = (
128
+ f"Table schema:\n"
129
+ f"Table name: data\n"
130
+ f"Columns:\n"
131
+ f"{columns_str}\n\n"
132
+ f"This SQL query failed:\n"
133
+ f"{original_sql}\n\n"
134
+ f"Error details:\n"
135
+ f"{error}\n\n"
136
+ f"Fix the query and write a correct SQLite SELECT query."
137
+ )
138
+
139
+ print("[LLMService] Sending fix request to Groq...")
140
+ completion = client.chat.completions.create(
141
+ model=config.GROQ_MODEL,
142
+ messages=[
143
+ {"role": "system", "content": system_prompt},
144
+ {"role": "user", "content": user_prompt}
145
+ ],
146
+ temperature=0.0
147
+ )
148
+
149
+ raw_response = completion.choices[0].message.content
150
+ fixed_sql = self._clean_sql_response(raw_response)
151
+
152
+ print(f"[LLMService] Fixed SQL:\n{fixed_sql}")
153
+ return {
154
+ "sql": fixed_sql
155
+ }
156
+
157
+ except Exception as e:
158
+ print(f"[LLMService] Error fixing SQL: {e}")
159
+ raise ValueError(f"Failed to fix SQL: {str(e)}")
160
+
161
+ def generate_question_suggestions(self, schema: dict) -> list:
162
+ """
163
+ Generates 6 analytical questions that a business user might ask about the table dataset.
164
+ """
165
+ print("[LLMService] Generating question suggestions from schema...")
166
+ try:
167
+ client = self._get_client()
168
+
169
+ # Format the columns section
170
+ columns_prompt = []
171
+ for col in schema.get("columns", []):
172
+ name = col.get("name")
173
+ col_type = col.get("type")
174
+ sample_vals = col.get("sample_values", [])
175
+ columns_prompt.append(f"- {name} ({col_type}): examples: {sample_vals}")
176
+
177
+ columns_str = "\n".join(columns_prompt)
178
+
179
+ system_prompt = (
180
+ "You are an expert data analyst assistant. Generate interesting question suggestions for a database schema.\n"
181
+ "Return ONLY a JSON array of strings, nothing else. No explanation, no markdown code block, no backticks."
182
+ )
183
+
184
+ user_prompt = (
185
+ f"Given a table with these columns:\n"
186
+ f"{columns_str}\n\n"
187
+ f"generate 6 interesting analytical questions a business user might ask.\n"
188
+ f"Return ONLY a JSON array of strings, nothing else."
189
+ )
190
+
191
+ completion = client.chat.completions.create(
192
+ model=config.GROQ_MODEL,
193
+ messages=[
194
+ {"role": "system", "content": system_prompt},
195
+ {"role": "user", "content": user_prompt}
196
+ ],
197
+ temperature=0.5
198
+ )
199
+
200
+ raw_response = completion.choices[0].message.content.strip()
201
+ print(f"[LLMService] Raw suggestions response: {raw_response}")
202
+
203
+ # Strip markdown block wraps if present
204
+ cleaned = raw_response
205
+ if cleaned.startswith("```"):
206
+ lines = cleaned.split("\n")
207
+ content_lines = [line for line in lines if not line.strip().startswith("```")]
208
+ cleaned = "".join(content_lines).strip()
209
+ cleaned = cleaned.strip("`").strip()
210
+
211
+ import json
212
+ suggestions = json.loads(cleaned)
213
+ if isinstance(suggestions, list) and len(suggestions) >= 6:
214
+ return suggestions[:6]
215
+ else:
216
+ raise ValueError("Response is not a valid list of 6 suggestions.")
217
+ except Exception as e:
218
+ print(f"[LLMService] Error generating suggestions: {e}. Falling back to default list.")
219
+ # safe fallback suggestions
220
+ return [
221
+ "What is the total number of records?",
222
+ "Show the first 10 rows of data.",
223
+ "Summary statistics of numeric columns.",
224
+ "How are the categories distributed?",
225
+ "Which column values have the highest values?",
226
+ "Filter and search records."
227
+ ]
228
+
229
+ def generate_sql_schema(self, question: str, schema_info: dict) -> dict:
230
+ """
231
+ Generates SQLite SELECT query using JOINs based on the user question and database relational schema.
232
+ """
233
+ print(f"[LLMService] Generating SQL (schema mode) for question: '{question}'")
234
+ try:
235
+ client = self._get_client()
236
+
237
+ # Format database schema tables
238
+ tables_prompt = []
239
+ for tbl in schema_info.get("tables", []):
240
+ tbl_name = tbl.get("name")
241
+ tbl_rows = tbl.get("row_count", 0)
242
+
243
+ columns_list = []
244
+ for col in tbl.get("columns", []):
245
+ name = col.get("name")
246
+ col_type = col.get("type")
247
+ is_pk = col.get("is_primary_key", False)
248
+ is_fk = col.get("is_foreign_key", False)
249
+ ref_tbl = col.get("references_table")
250
+ ref_col = col.get("references_column")
251
+
252
+ pk_str = " [PRIMARY KEY]" if is_pk else ""
253
+ fk_str = f" [FK → {ref_tbl}.{ref_col}]" if is_fk and ref_tbl and ref_col else ""
254
+ columns_list.append(f" - {name} ({col_type}){pk_str}{fk_str}")
255
+
256
+ columns_str = "\n".join(columns_list)
257
+
258
+ # Fetch sample rows and format as clean text
259
+ sample_vals = []
260
+ col_samples_len = max([len(c.get("sample_values", [])) for c in tbl.get("columns", [])]) if tbl.get("columns") else 0
261
+ for r_idx in range(min(col_samples_len, 3)):
262
+ row_val = {}
263
+ for col in tbl.get("columns", []):
264
+ samples = col.get("sample_values", [])
265
+ if r_idx < len(samples):
266
+ row_val[col["name"]] = samples[r_idx]
267
+ sample_vals.append(row_val)
268
+
269
+ tables_prompt.append(
270
+ f"Table: {tbl_name} ({tbl_rows} rows)\n"
271
+ f"Columns:\n"
272
+ f"{columns_str}\n"
273
+ f"Sample data (first 3 rows): {str(sample_vals)}"
274
+ )
275
+
276
+ tables_str = "\n\n".join(tables_prompt)
277
+
278
+ # Format relationships
279
+ rel_prompt = []
280
+ for rel in schema_info.get("relationships", []):
281
+ from_table = rel.get("from_table")
282
+ from_column = rel.get("from_column")
283
+ to_table = rel.get("to_table")
284
+ to_column = rel.get("to_column")
285
+ cardinality = rel.get("cardinality", "1:N")
286
+ from_participation = rel.get("from_participation", "total")
287
+ to_participation = rel.get("to_participation", "partial")
288
+ rel_prompt.append(
289
+ f" {from_table}.{from_column} → {to_table}.{to_column}\n"
290
+ f" Type: {cardinality} | {from_table} participation: {from_participation} | {to_table} participation: {to_participation}"
291
+ )
292
+
293
+ relationships_str = "\n".join(rel_prompt)
294
+
295
+ system_prompt = (
296
+ "You are an expert SQL analyst working with a relational SQLite database.\n"
297
+ "Generate SELECT queries only. Never use INSERT, UPDATE, DELETE, DROP, or ALTER.\n"
298
+ "Use JOINs when the question requires data from multiple tables.\n"
299
+ "Use meaningful table aliases: c for customers, o for orders, "
300
+ "p for products, oi for order_items, r for reviews.\n"
301
+ "Always alias aggregated columns with readable names.\n"
302
+ "Limit results to 20 rows unless the question specifically asks for all.\n"
303
+ "Return ONLY the raw SQL query — no explanation, no markdown, no backticks."
304
+ )
305
+
306
+ user_prompt = (
307
+ f"Database schema:\n\n"
308
+ f"{tables_str}\n\n"
309
+ f"Relationships:\n"
310
+ f"{relationships_str}\n\n"
311
+ f"Question: {question}\n\n"
312
+ f"Write a SQLite SELECT query using JOINs as needed."
313
+ )
314
+
315
+ print("[LLMService] Sending generation request to Groq...")
316
+ completion = client.chat.completions.create(
317
+ model=config.GROQ_MODEL,
318
+ messages=[
319
+ {"role": "system", "content": system_prompt},
320
+ {"role": "user", "content": user_prompt}
321
+ ],
322
+ temperature=0.0
323
+ )
324
+
325
+ raw_response = completion.choices[0].message.content
326
+ sql = self._clean_sql_response(raw_response)
327
+
328
+ print(f"[LLMService] Generated Schema SQL:\n{sql}")
329
+ return {
330
+ "sql": sql,
331
+ "raw_response": raw_response
332
+ }
333
+
334
+ except Exception as e:
335
+ print(f"[LLMService] Error generating Schema SQL: {e}")
336
+ raise ValueError(f"Failed to generate SQL for schema database: {str(e)}")
337
+
338
+ def fix_sql_schema(self, original_sql: str, error: str, schema_info: dict) -> dict:
339
+ """
340
+ Takes a failing schema-mode SQL query, the execution error, and the schema info,
341
+ and requests the LLM to correct the query.
342
+ """
343
+ print(f"[LLMService] Fixing SQL (schema mode): '{original_sql}' due to error: '{error}'")
344
+ try:
345
+ client = self._get_client()
346
+
347
+ # Format database schema tables
348
+ tables_prompt = []
349
+ for tbl in schema_info.get("tables", []):
350
+ tbl_name = tbl.get("name")
351
+ tbl_rows = tbl.get("row_count", 0)
352
+
353
+ columns_list = []
354
+ for col in tbl.get("columns", []):
355
+ name = col.get("name")
356
+ col_type = col.get("type")
357
+ is_pk = col.get("is_primary_key", False)
358
+ is_fk = col.get("is_foreign_key", False)
359
+ ref_tbl = col.get("references_table")
360
+ ref_col = col.get("references_column")
361
+
362
+ pk_str = " [PRIMARY KEY]" if is_pk else ""
363
+ fk_str = f" [FK → {ref_tbl}.{ref_col}]" if is_fk and ref_tbl and ref_col else ""
364
+ columns_list.append(f" - {name} ({col_type}){pk_str}{fk_str}")
365
+
366
+ columns_str = "\n".join(columns_list)
367
+
368
+ col_samples_len = max([len(c.get("sample_values", [])) for c in tbl.get("columns", [])]) if tbl.get("columns") else 0
369
+ sample_vals = []
370
+ for r_idx in range(min(col_samples_len, 3)):
371
+ row_val = {}
372
+ for col in tbl.get("columns", []):
373
+ samples = col.get("sample_values", [])
374
+ if r_idx < len(samples):
375
+ row_val[col["name"]] = samples[r_idx]
376
+ sample_vals.append(row_val)
377
+
378
+ tables_prompt.append(
379
+ f"Table: {tbl_name} ({tbl_rows} rows)\n"
380
+ f"Columns:\n"
381
+ f"{columns_str}\n"
382
+ f"Sample data (first 3 rows): {str(sample_vals)}"
383
+ )
384
+
385
+ tables_str = "\n\n".join(tables_prompt)
386
+
387
+ # Format relationships
388
+ rel_prompt = []
389
+ for rel in schema_info.get("relationships", []):
390
+ from_table = rel.get("from_table")
391
+ from_column = rel.get("from_column")
392
+ to_table = rel.get("to_table")
393
+ to_column = rel.get("to_column")
394
+ cardinality = rel.get("cardinality", "1:N")
395
+ from_participation = rel.get("from_participation", "total")
396
+ to_participation = rel.get("to_participation", "partial")
397
+ rel_prompt.append(
398
+ f" {from_table}.{from_column} → {to_table}.{to_column}\n"
399
+ f" Type: {cardinality} | {from_table} participation: {from_participation} | {to_table} participation: {to_participation}"
400
+ )
401
+
402
+ relationships_str = "\n".join(rel_prompt)
403
+
404
+ system_prompt = (
405
+ "You are an expert SQL analyst. Correct the failing SQLite SQL query.\n"
406
+ "Generate SELECT queries only. Never use INSERT, UPDATE, DELETE, DROP, or ALTER.\n"
407
+ "Return ONLY the corrected SQL query, no explanation, no markdown, no backticks."
408
+ )
409
+
410
+ user_prompt = (
411
+ f"Database schema:\n\n"
412
+ f"{tables_str}\n\n"
413
+ f"Relationships:\n"
414
+ f"{relationships_str}\n\n"
415
+ f"This SQL query failed:\n"
416
+ f"{original_sql}\n\n"
417
+ f"Error details:\n"
418
+ f"{error}\n\n"
419
+ f"Fix the query and write a correct SQLite SELECT query."
420
+ )
421
+
422
+ print("[LLMService] Sending fix request to Groq...")
423
+ completion = client.chat.completions.create(
424
+ model=config.GROQ_MODEL,
425
+ messages=[
426
+ {"role": "system", "content": system_prompt},
427
+ {"role": "user", "content": user_prompt}
428
+ ],
429
+ temperature=0.0
430
+ )
431
+
432
+ raw_response = completion.choices[0].message.content
433
+ fixed_sql = self._clean_sql_response(raw_response)
434
+
435
+ print(f"[LLMService] Fixed Schema SQL:\n{fixed_sql}")
436
+ return {
437
+ "sql": fixed_sql
438
+ }
439
+
440
+ except Exception as e:
441
+ print(f"[LLMService] Error fixing Schema SQL: {e}")
442
+ raise ValueError(f"Failed to fix SQL: {str(e)}")
443
+
444
+ # Singleton instance
445
+ llm_service = LLMService()
446
+
backend/services/schema_service.py ADDED
@@ -0,0 +1,923 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import sqlite3
4
+ import shutil
5
+ import base64
6
+ import mimetypes
7
+ import json
8
+ from typing import Dict, Any, List
9
+ from groq import Groq
10
+ from backend import config
11
+
12
+ class SchemaService:
13
+ def __init__(self):
14
+ print("[SchemaService] Initializing SchemaService singleton...")
15
+ os.makedirs(config.SCHEMA_DB_DIR, exist_ok=True)
16
+
17
+ def clean_tsql_to_sqlite(self, raw_sql: str) -> tuple:
18
+ """
19
+ Takes a raw SQL script (possibly T-SQL / SQL Server syntax) and returns
20
+ a clean SQLite-compatible SQL string plus any extra relationships extracted
21
+ from ALTER TABLE statements.
22
+
23
+ Returns:
24
+ tuple: (cleaned_sql: str, extra_relationships: list)
25
+ """
26
+ print("[SchemaService] Running T-SQL to SQLite conversion...")
27
+
28
+ # Normalize line endings
29
+ sql = raw_sql.replace('\r\n', '\n')
30
+
31
+ # Remove comments containing T-SQL specific syntax hints
32
+ sql = re.sub(r'/\*[^*]*(?:WITH\s*\(\s*NOLOCK\s*\)|NOLOCK)[^*]*\*/', '', sql, flags=re.IGNORECASE)
33
+
34
+ # STEP 1 — Remove SQL Server specific statements entirely
35
+ # Remove IF NOT EXISTS ... BEGIN ... END blocks (multi-line, non-greedy)
36
+ sql = re.sub(
37
+ r'(?smi)\bIF\s+NOT\s+EXISTS\s*\(.*?\)\s*BEGIN\s*.*?END\s*;?',
38
+ '', sql
39
+ )
40
+
41
+ # Remove IF OBJECT_ID(...) IS NOT NULL DROP TABLE ... blocks
42
+ sql = re.sub(
43
+ r'(?smi)\bIF\s+OBJECT_ID\s*\(.*?\)\s+IS\s+NOT\s+NULL\s+DROP\s+TABLE\s+[^;\n]+;?',
44
+ '', sql
45
+ )
46
+
47
+ # Remove other IF EXISTS ... BEGIN ... END blocks
48
+ sql = re.sub(
49
+ r'(?smi)\bIF\s+EXISTS\s*\(.*?\)\s*BEGIN\s*.*?END\s*;?',
50
+ '', sql
51
+ )
52
+
53
+ # Remove lines containing specific SQL Server statements entirely
54
+ lines = sql.split('\n')
55
+ cleaned_lines = []
56
+ for line in lines:
57
+ stripped = line.strip().upper()
58
+
59
+ # Skip GO batch separator
60
+ if stripped == 'GO' or stripped == 'GO;':
61
+ continue
62
+ # Skip PRINT statements
63
+ if 'PRINT' in stripped:
64
+ continue
65
+ # Skip USE statements
66
+ if 'USE ' in stripped or re.search(r'\bUSE\b', stripped):
67
+ continue
68
+ # Skip lines referencing system objects
69
+ if any(kw in stripped for kw in ['SYS.TABLES', 'SYS.DATABASES', 'SYS.OBJECTS']):
70
+ continue
71
+ # Skip OBJECT_ID references
72
+ if 'OBJECT_ID(' in stripped:
73
+ continue
74
+ # Skip SET NOCOUNT, SET ANSI, SET QUOTED
75
+ if any(kw in stripped for kw in ['SET NOCOUNT', 'SET ANSI', 'SET QUOTED']):
76
+ continue
77
+ # Skip EXEC / EXECUTE statements
78
+ if 'EXEC' in re.findall(r'\bEXEC\b', stripped) or 'EXECUTE' in re.findall(r'\bEXECUTE\b', stripped):
79
+ continue
80
+
81
+ # Remove WITH (NOLOCK) hints inline (keep the rest of the line)
82
+ line = re.sub(r'\bWITH\s*\(\s*NOLOCK\s*\)', '', line, flags=re.IGNORECASE)
83
+ cleaned_lines.append(line)
84
+
85
+ sql = '\n'.join(cleaned_lines)
86
+
87
+ # Let's split into statements by semicolon, clean each, and rejoin
88
+ statements = re.split(r';', sql)
89
+ cleaned_statements = []
90
+ extra_relationships = []
91
+
92
+ # ALTER TABLE foreign key constraint pattern
93
+ alter_fk_pattern = re.compile(
94
+ r'ALTER\s+TABLE\s+(\w+)\s+ADD\s+CONSTRAINT\s+\w+\s+'
95
+ r'FOREIGN\s+KEY\s*\(\s*(\w+)\s*\)\s*'
96
+ r'REFERENCES\s+(\w+)\s*\(\s*(\w+)\s*\)',
97
+ re.IGNORECASE | re.DOTALL
98
+ )
99
+
100
+ # Helper to clean CHECK constraints that reference T-SQL functions or subqueries
101
+ def clean_check_constraints(sql_chunk: str) -> str:
102
+ pos = 0
103
+ while True:
104
+ match = re.search(r'\bCHECK\b', sql_chunk[pos:], re.IGNORECASE)
105
+ if not match:
106
+ break
107
+ start_idx = pos + match.start()
108
+ open_paren_idx = sql_chunk.find('(', start_idx)
109
+ if open_paren_idx == -1:
110
+ pos = start_idx + 5
111
+ continue
112
+ paren_depth = 0
113
+ close_paren_idx = -1
114
+ for i in range(open_paren_idx, len(sql_chunk)):
115
+ if sql_chunk[i] == '(':
116
+ paren_depth += 1
117
+ elif sql_chunk[i] == ')':
118
+ paren_depth -= 1
119
+ if paren_depth == 0:
120
+ close_paren_idx = i
121
+ break
122
+ if close_paren_idx == -1:
123
+ pos = start_idx + 5
124
+ continue
125
+
126
+ check_expr = sql_chunk[start_idx:close_paren_idx + 1]
127
+ if any(kw in check_expr.upper() for kw in ['GETDATE', 'SYSDATETIME', 'GETUTCDATE', 'NEWID', 'SELECT']):
128
+ # Remove the CHECK expression
129
+ sql_chunk = sql_chunk[:start_idx] + sql_chunk[close_paren_idx + 1:]
130
+ pos = start_idx
131
+ else:
132
+ pos = close_paren_idx + 1
133
+ return sql_chunk
134
+
135
+ for stmt in statements:
136
+ stmt = stmt.strip()
137
+ if not stmt:
138
+ continue
139
+
140
+ # If it's just comments, keep it as is
141
+ lines_only = re.sub(r'--.*$', '', stmt, flags=re.MULTILINE).strip()
142
+ lines_only = re.sub(r'/\*.*?\*/', '', lines_only, flags=re.DOTALL).strip()
143
+ if not lines_only:
144
+ cleaned_statements.append(stmt + ';')
145
+ continue
146
+
147
+ # Discard statement if it contains leftovers from line removal or system tables
148
+ stmt_upper = stmt.upper()
149
+ if any(kw in stmt_upper for kw in ['SYS.TABLES', 'SYS.DATABASES', 'SYS.OBJECTS', 'OBJECT_ID(']):
150
+ continue
151
+ if stmt_upper.startswith('SELECT') and 'FROM' not in stmt_upper:
152
+ continue
153
+
154
+ # STEP 6 — Handle schema prefixes and brackets (run early so ALTER TABLE matches clean names)
155
+ stmt = re.sub(r'\[dbo\]\.\[([^\]]+)\]', r'\1', stmt, flags=re.IGNORECASE)
156
+ stmt = re.sub(r'\bdbo\.\[([^\]]+)\]', r'\1', stmt, flags=re.IGNORECASE)
157
+ stmt = re.sub(r'\[dbo\]\.(\w+)', r'\1', stmt, flags=re.IGNORECASE)
158
+ stmt = re.sub(r'\bdbo\.(\w+)', r'\1', stmt, flags=re.IGNORECASE)
159
+ stmt = re.sub(r'\[([^\]]+)\]', r'\1', stmt)
160
+
161
+ # STEP 3 — Handle ALTER TABLE ADD CONSTRAINT FOREIGN KEY statements
162
+ m = alter_fk_pattern.search(stmt)
163
+ if m:
164
+ extra_relationships.append({
165
+ "from_table": m.group(1).lower(),
166
+ "from_column": m.group(2).lower(),
167
+ "to_table": m.group(3).lower(),
168
+ "to_column": m.group(4).lower(),
169
+ "source": "alter_table"
170
+ })
171
+ # Skip writing the ALTER TABLE statement
172
+ continue
173
+
174
+ # Also skip other ALTER TABLE constraints SQLite doesn't support
175
+ if re.search(r'\bALTER\s+TABLE\s+\w+\s+ADD\s+CONSTRAINT\b', stmt, re.IGNORECASE):
176
+ continue
177
+
178
+ # STEP 2 — Convert data types
179
+ type_map = [
180
+ (r'\bDATETIME2\b', 'TEXT'),
181
+ (r'\bDATETIME\b', 'TEXT'),
182
+ (r'\bSMALLDATETIME\b', 'TEXT'),
183
+ (r'\bNVARCHAR\s*\(\s*MAX\s*\)', 'TEXT'),
184
+ (r'\bVARCHAR\s*\(\s*MAX\s*\)', 'TEXT'),
185
+ (r'\bNVARCHAR\s*\((\s*\d+\s*)\)', r'VARCHAR(\1)'),
186
+ (r'\bNCHAR\s*\((\s*\d+\s*)\)', r'CHAR(\1)'),
187
+ (r'\bNTEXT\b', 'TEXT'),
188
+ (r'\bUNIQUEIDENTIFIER\b', 'TEXT'),
189
+ (r'\bSMALLMONEY\b', 'DECIMAL(6,2)'),
190
+ (r'\bMONEY\b', 'DECIMAL(15,2)'),
191
+ (r'\bTINYINT\b', 'INTEGER'),
192
+ (r'\bSMALLINT\b', 'INTEGER'),
193
+ (r'\bBIGINT\b', 'INTEGER'),
194
+ (r'\bBIT\b', 'INTEGER'),
195
+ (r'\bVARBINARY\s*\([^)]*\)', 'BLOB'),
196
+ (r'\bVARBINARY\b', 'BLOB'),
197
+ (r'\bIMAGE\b', 'BLOB'),
198
+ (r'\bFLOAT\b', 'REAL'),
199
+ (r'\bREAL\b', 'REAL'),
200
+ ]
201
+ for pattern, replacement in type_map:
202
+ stmt = re.sub(pattern, replacement, stmt, flags=re.IGNORECASE)
203
+
204
+ # STEP 4 — Handle CHECK constraints
205
+ stmt = clean_check_constraints(stmt)
206
+
207
+ # Clean up commas and spacing inside statement
208
+ # Remove multiple commas: e.g. ", ," to ","
209
+ stmt = re.sub(r',\s*,', ',', stmt)
210
+ # Remove trailing comma before closing parenthesis: e.g. ", )" to ")"
211
+ stmt = re.sub(r',\s*\)', ')', stmt)
212
+
213
+ stmt = stmt.strip()
214
+ if stmt:
215
+ cleaned_statements.append(stmt + ';')
216
+
217
+ sql = '\n\n'.join(cleaned_statements)
218
+
219
+ # Remove multiple consecutive blank lines
220
+ sql = re.sub(r'\n{3,}', '\n\n', sql).strip()
221
+
222
+ print(f"[SchemaService] T-SQL conversion complete. Extracted {len(extra_relationships)} ALTER TABLE relationships.")
223
+ return (sql, extra_relationships)
224
+
225
+
226
+ def parse_schema_sql(self, schema_sql_content: str) -> dict:
227
+ """
228
+ Parses the raw DDL schema text using regex to extract all tables,
229
+ columns, primary keys, and foreign keys.
230
+ """
231
+ print("[SchemaService] Parsing SQL schema content...")
232
+ # 1. Clean the SQL file comments and whitespace
233
+ sql_clean = re.sub(r"--.*?\n", "\n", schema_sql_content)
234
+ sql_clean = re.sub(r"/\*.*?\*/", "", sql_clean, flags=re.DOTALL)
235
+
236
+ # 2. Match CREATE TABLE statements
237
+ table_matches = re.finditer(r"CREATE\s+TABLE\s+(\w+)\s*\((.*?)\);", sql_clean, re.IGNORECASE | re.DOTALL)
238
+
239
+ tables = []
240
+ relationships = []
241
+
242
+ for match in table_matches:
243
+ table_name = match.group(1).lower().strip()
244
+ inner_content = match.group(2).strip()
245
+
246
+ # Split definitions by comma, ignoring nested commas inside parentheses (e.g. DECIMAL(10,2))
247
+ defs = []
248
+ current = []
249
+ paren_count = 0
250
+ for char in inner_content:
251
+ if char == '(':
252
+ paren_count += 1
253
+ current.append(char)
254
+ elif char == ')':
255
+ paren_count -= 1
256
+ current.append(char)
257
+ elif char == ',' and paren_count == 0:
258
+ defs.append("".join(current).strip())
259
+ current = []
260
+ else:
261
+ current.append(char)
262
+ if current:
263
+ defs.append("".join(current).strip())
264
+
265
+ columns = []
266
+ table_fk_constraints = []
267
+
268
+ for d in defs:
269
+ if not d:
270
+ continue
271
+
272
+ # Check if this line is a table-level FOREIGN KEY constraint:
273
+ # e.g., FOREIGN KEY (customer_id) REFERENCES customers(customer_id)
274
+ fk_match = re.search(r"FOREIGN\s+KEY\s*\(\s*(\w+)\s*\)\s*REFERENCES\s*(\w+)\s*\(\s*(\w+)\s*\)", d, re.IGNORECASE)
275
+ if fk_match:
276
+ from_col = fk_match.group(1).lower().strip()
277
+ to_tbl = fk_match.group(2).lower().strip()
278
+ to_col = fk_match.group(3).lower().strip()
279
+ table_fk_constraints.append({
280
+ "from_column": from_col,
281
+ "to_table": to_tbl,
282
+ "to_column": to_col
283
+ })
284
+ relationships.append({
285
+ "from_table": table_name,
286
+ "from_column": from_col,
287
+ "to_table": to_tbl,
288
+ "to_column": to_col
289
+ })
290
+ continue
291
+
292
+ # Check if this line is a table-level PRIMARY KEY constraint:
293
+ pk_match = re.search(r"PRIMARY\s+KEY\s*\(\s*(\w+)\s*\)", d, re.IGNORECASE)
294
+ if pk_match:
295
+ pk_col = pk_match.group(1).lower().strip()
296
+ for col in columns:
297
+ if col["name"] == pk_col:
298
+ col["is_primary_key"] = True
299
+ continue
300
+
301
+ # Otherwise, it's a column definition
302
+ parts = d.split()
303
+ if not parts:
304
+ continue
305
+ col_name = parts[0].lower().strip()
306
+ col_name = col_name.strip("`\"'")
307
+
308
+ col_type = parts[1].upper().strip() if len(parts) > 1 else "TEXT"
309
+ col_type = re.sub(r"\(.*?\)", "", col_type)
310
+
311
+ is_pk = False
312
+ if "PRIMARY" in d.upper() and "KEY" in d.upper() and "FOREIGN" not in d.upper():
313
+ is_pk = True
314
+
315
+ inline_ref = re.search(r"REFERENCES\s+(\w+)\s*\(\s*(\w+)\s*\)", d, re.IGNORECASE)
316
+
317
+ col_info = {
318
+ "name": col_name,
319
+ "type": col_type,
320
+ "is_primary_key": is_pk,
321
+ "is_foreign_key": False,
322
+ "references_table": None,
323
+ "references_column": None,
324
+ "is_not_null": "NOT NULL" in d.upper() or is_pk
325
+ }
326
+
327
+ if inline_ref:
328
+ col_info["is_foreign_key"] = True
329
+ col_info["references_table"] = inline_ref.group(1).lower().strip()
330
+ col_info["references_column"] = inline_ref.group(2).lower().strip()
331
+ relationships.append({
332
+ "from_table": table_name,
333
+ "from_column": col_name,
334
+ "to_table": col_info["references_table"],
335
+ "to_column": col_info["references_column"]
336
+ })
337
+
338
+ columns.append(col_info)
339
+
340
+ # Enrich columns with table-level FK constraints
341
+ for fk in table_fk_constraints:
342
+ for col in columns:
343
+ if col["name"] == fk["from_column"]:
344
+ col["is_foreign_key"] = True
345
+ col["references_table"] = fk["to_table"]
346
+ col["references_column"] = fk["to_column"]
347
+
348
+ tables.append({
349
+ "name": table_name,
350
+ "columns": columns
351
+ })
352
+
353
+ return {
354
+ "tables": tables,
355
+ "relationships": relationships
356
+ }
357
+
358
+ def enrich_schema_db(self, db_name: str, schema_info: dict, db_path: str) -> dict:
359
+ """
360
+ Enriches the parsed schema with live table statistics and sample values.
361
+ """
362
+ print(f"[SchemaService] Enriches database '{db_name}' metadata...")
363
+ conn = sqlite3.connect(db_path)
364
+ cursor = conn.cursor()
365
+
366
+ total_rows = 0
367
+ enriched_tables = []
368
+
369
+ for tbl in schema_info["tables"]:
370
+ tbl_name = tbl["name"]
371
+
372
+ try:
373
+ cursor.execute(f"SELECT COUNT(*) FROM {tbl_name}")
374
+ tbl_rows = cursor.fetchone()[0]
375
+ except Exception:
376
+ tbl_rows = 0
377
+ total_rows += tbl_rows
378
+
379
+ sample_rows = []
380
+ try:
381
+ col_names_str = ", ".join([f'"{col["name"]}"' for col in tbl["columns"]])
382
+ cursor.execute(f"SELECT {col_names_str} FROM {tbl_name} LIMIT 3")
383
+ sample_rows = cursor.fetchall()
384
+ except Exception as e:
385
+ print(f"[SchemaService] Error getting sample rows for {tbl_name}: {e}")
386
+
387
+ enriched_cols = []
388
+ for col_idx, col in enumerate(tbl["columns"]):
389
+ col_samples = []
390
+ for row in sample_rows:
391
+ if col_idx < len(row):
392
+ val = row[col_idx]
393
+ if val is not None:
394
+ col_samples.append(val)
395
+ col["sample_values"] = col_samples
396
+ enriched_cols.append(col)
397
+
398
+ enriched_tables.append({
399
+ "name": tbl_name,
400
+ "columns": enriched_cols,
401
+ "row_count": tbl_rows
402
+ })
403
+
404
+ conn.close()
405
+
406
+ return {
407
+ "db_name": db_name,
408
+ "mode": "schema",
409
+ "tables": enriched_tables,
410
+ "relationships": schema_info["relationships"],
411
+ "total_tables": len(enriched_tables),
412
+ "total_rows": total_rows
413
+ }
414
+
415
+ def extract_schema_from_erd_image(self, image_path: str) -> dict:
416
+ """
417
+ Extracts database schema from the ERD diagram image using Groq vision API.
418
+ """
419
+ print(f"[SchemaService] Extracting schema from image: {image_path}")
420
+ with open(image_path, "rb") as f:
421
+ base64_image = base64.b64encode(f.read()).decode("utf-8")
422
+
423
+ mime_type, _ = mimetypes.guess_type(image_path)
424
+ if not mime_type:
425
+ if image_path.lower().endswith(".png"):
426
+ mime_type = "image/png"
427
+ elif image_path.lower().endswith((".jpg", ".jpeg")):
428
+ mime_type = "image/jpeg"
429
+ elif image_path.lower().endswith(".pdf"):
430
+ mime_type = "application/pdf"
431
+ else:
432
+ mime_type = "image/png"
433
+
434
+ client = Groq(api_key=config.GROQ_API_KEY)
435
+
436
+ completion = client.chat.completions.create(
437
+ model="meta-llama/llama-4-scout-17b-16e-instruct",
438
+ messages=[
439
+ {
440
+ "role": "user",
441
+ "content": [
442
+ {
443
+ "type": "image_url",
444
+ "image_url": {
445
+ "url": f"data:{mime_type};base64,{base64_image}"
446
+ }
447
+ },
448
+ {
449
+ "type": "text",
450
+ "text": """Analyze this Entity Relationship Diagram (ERD) carefully.
451
+ Extract the complete database schema and return ONLY a JSON object
452
+ with this exact structure, nothing else:
453
+ {
454
+ "tables": [
455
+ {
456
+ "name": "table_name",
457
+ "columns": [
458
+ {
459
+ "name": "column_name",
460
+ "type": "TEXT|INTEGER|REAL|BLOB",
461
+ "is_primary_key": true|false,
462
+ "is_foreign_key": false,
463
+ "references_table": null,
464
+ "references_column": null
465
+ }
466
+ ]
467
+ }
468
+ ],
469
+ "relationships": [
470
+ {
471
+ "from_table": "table_a",
472
+ "from_column": "col_a",
473
+ "to_table": "table_b",
474
+ "to_column": "col_b",
475
+ "cardinality": "1:1|1:N|N:M",
476
+ "from_participation": "total|partial",
477
+ "to_participation": "total|partial",
478
+ "relationship_name": "places|contains|belongs_to|etc"
479
+ }
480
+ ]
481
+ }
482
+
483
+ For cardinality:
484
+ - 1:1 means one record in table_a relates to exactly one in table_b
485
+ - 1:N means one record in table_a relates to many in table_b
486
+ - N:M means many records in table_a relate to many in table_b
487
+
488
+ For participation:
489
+ - total means every record MUST participate (double line in ERD)
490
+ - partial means participation is optional (single line in ERD)
491
+
492
+ Look carefully at crow's foot notation, double lines, dashed lines,
493
+ min-max notation, or any other ERD notation style used in the image.
494
+ Infer participation and cardinality as accurately as possible."""
495
+ }
496
+ ]
497
+ }
498
+ ],
499
+ max_tokens=2000
500
+ )
501
+
502
+ raw_response = completion.choices[0].message.content.strip()
503
+ cleaned = raw_response
504
+ if cleaned.startswith("```"):
505
+ lines = cleaned.split("\n")
506
+ content_lines = [line for line in lines if not line.strip().startswith("```")]
507
+ cleaned = "".join(content_lines).strip()
508
+ cleaned = cleaned.strip("`").strip()
509
+
510
+ json_start = cleaned.find("{")
511
+ json_end = cleaned.rfind("}")
512
+ if json_start != -1 and json_end != -1:
513
+ cleaned = cleaned[json_start:json_end+1]
514
+
515
+ return json.loads(cleaned)
516
+
517
+ def enrich_relationships_from_erd_image(self, existing_schema: dict, image_path: str) -> dict:
518
+ """
519
+ Enriches relationships in existing schema using the ERD diagram image via Groq vision API.
520
+ """
521
+ print(f"[SchemaService] Enriching schema relationships from image: {image_path}")
522
+ with open(image_path, "rb") as f:
523
+ base64_image = base64.b64encode(f.read()).decode("utf-8")
524
+
525
+ mime_type, _ = mimetypes.guess_type(image_path)
526
+ if not mime_type:
527
+ if image_path.lower().endswith(".png"):
528
+ mime_type = "image/png"
529
+ elif image_path.lower().endswith((".jpg", ".jpeg")):
530
+ mime_type = "image/jpeg"
531
+ elif image_path.lower().endswith(".pdf"):
532
+ mime_type = "application/pdf"
533
+ else:
534
+ mime_type = "image/png"
535
+
536
+ client = Groq(api_key=config.GROQ_API_KEY)
537
+
538
+ schema_text = json.dumps(existing_schema, indent=2)
539
+
540
+ completion = client.chat.completions.create(
541
+ model="meta-llama/llama-4-scout-17b-16e-instruct",
542
+ messages=[
543
+ {
544
+ "role": "user",
545
+ "content": [
546
+ {
547
+ "type": "image_url",
548
+ "image_url": {
549
+ "url": f"data:{mime_type};base64,{base64_image}"
550
+ }
551
+ },
552
+ {
553
+ "type": "text",
554
+ "text": f"""This ERD diagram corresponds to the following database schema:
555
+ {schema_text}
556
+
557
+ For each relationship shown in this diagram, extract:
558
+ - cardinality (1:1, 1:N, or N:M)
559
+ - from_participation (total or partial)
560
+ - to_participation (total or partial)
561
+ - relationship_name (the verb/label on the relationship line if visible)
562
+
563
+ Return ONLY a JSON array:
564
+ [
565
+ {{
566
+ 'from_table': '...', 'to_table': '...',
567
+ 'cardinality': '1:N',
568
+ 'from_participation': 'partial',
569
+ 'to_participation': 'total',
570
+ 'relationship_name': 'places'
571
+ }}
572
+ ]"""
573
+ }
574
+ ]
575
+ }
576
+ ],
577
+ max_tokens=2000
578
+ )
579
+
580
+ raw_response = completion.choices[0].message.content.strip()
581
+ cleaned = raw_response
582
+ if cleaned.startswith("```"):
583
+ lines = cleaned.split("\n")
584
+ content_lines = [line for line in lines if not line.strip().startswith("```")]
585
+ cleaned = "".join(content_lines).strip()
586
+ cleaned = cleaned.strip("`").strip()
587
+
588
+ json_start = cleaned.find("[")
589
+ json_end = cleaned.rfind("]")
590
+ if json_start != -1 and json_end != -1:
591
+ cleaned = cleaned[json_start:json_end+1]
592
+
593
+ try:
594
+ enriched_rels = json.loads(cleaned)
595
+ except Exception:
596
+ try:
597
+ import ast
598
+ enriched_rels = ast.literal_eval(cleaned)
599
+ except Exception:
600
+ enriched_rels = []
601
+
602
+ rel_lookup = {}
603
+ for r in enriched_rels:
604
+ from_t = r.get("from_table", "").lower().strip()
605
+ to_t = r.get("to_table", "").lower().strip()
606
+ rel_lookup[(from_t, to_t)] = r
607
+
608
+ for rel in existing_schema.get("relationships", []):
609
+ from_t = rel.get("from_table", "").lower().strip()
610
+ to_t = rel.get("to_table", "").lower().strip()
611
+
612
+ match = rel_lookup.get((from_t, to_t))
613
+ if not match:
614
+ match = rel_lookup.get((to_t, from_t))
615
+
616
+ if match:
617
+ rel["cardinality"] = match.get("cardinality", "1:N")
618
+ rel["from_participation"] = match.get("from_participation", "total")
619
+ rel["to_participation"] = match.get("to_participation", "partial")
620
+ rel["relationship_name"] = match.get("relationship_name")
621
+ else:
622
+ # Default assumptions
623
+ rel["cardinality"] = "1:N"
624
+ rel["from_participation"] = "total"
625
+ rel["to_participation"] = "partial"
626
+ rel["relationship_name"] = None
627
+
628
+ return existing_schema
629
+
630
+ def infer_cardinality_from_sql(self, relationships: list, tables: list = None) -> list:
631
+ """
632
+ Infers relationship constraints from SQL database schema definition.
633
+ """
634
+ if tables is None:
635
+ tables = []
636
+
637
+ junction_tables = set()
638
+ for tbl in tables:
639
+ fks = [c for c in tbl.get("columns", []) if c.get("is_foreign_key")]
640
+ if len(fks) >= 2:
641
+ other_cols = [c for c in tbl.get("columns", []) if not c.get("is_foreign_key") and not c.get("is_primary_key") and c.get("name").lower() not in ("id", "created_at", "updated_at", "timestamp")]
642
+ if len(other_cols) <= 1:
643
+ junction_tables.add(tbl.get("name").lower())
644
+
645
+ for rel in relationships:
646
+ from_table = rel.get("from_table", "").lower()
647
+ from_column = rel.get("from_column", "").lower()
648
+ to_table = rel.get("to_table", "").lower()
649
+ to_column = rel.get("to_column", "").lower()
650
+
651
+ from_tbl_def = next((t for t in tables if t.get("name", "").lower() == from_table), None)
652
+ to_tbl_def = next((t for t in tables if t.get("name", "").lower() == to_table), None)
653
+
654
+ from_col_def = None
655
+ if from_tbl_def:
656
+ from_col_def = next((c for c in from_tbl_def.get("columns", []) if c.get("name", "").lower() == from_column), None)
657
+
658
+ to_col_def = None
659
+ if to_tbl_def:
660
+ to_col_def = next((c for c in to_tbl_def.get("columns", []) if c.get("name", "").lower() == to_column), None)
661
+
662
+ if from_table in junction_tables:
663
+ cardinality = "N:M"
664
+ else:
665
+ is_from_pk = from_col_def.get("is_primary_key", False) if from_col_def else False
666
+ if is_from_pk:
667
+ is_to_pk = to_col_def.get("is_primary_key", False) if to_col_def else False
668
+ if is_to_pk:
669
+ cardinality = "1:1"
670
+ else:
671
+ cardinality = "1:N"
672
+ else:
673
+ cardinality = "1:N"
674
+
675
+ to_participation = "partial"
676
+ is_not_null = False
677
+ if from_col_def:
678
+ is_not_null = from_col_def.get("is_primary_key", False) or from_col_def.get("is_not_null", False)
679
+
680
+ from_participation = "total" if is_not_null else "partial"
681
+
682
+ rel["cardinality"] = cardinality
683
+ rel["from_participation"] = from_participation
684
+ rel["to_participation"] = to_participation
685
+ rel["relationship_name"] = None
686
+
687
+ return relationships
688
+
689
+ def generate_sql_from_parsed_schema(self, schema_info: dict) -> str:
690
+ """
691
+ Helper to construct a .sql schema file if only the ERD image was uploaded.
692
+ """
693
+ lines = []
694
+ for tbl in schema_info.get("tables", []):
695
+ tbl_name = tbl["name"]
696
+ col_defs = []
697
+ for col in tbl.get("columns", []):
698
+ col_name = col["name"]
699
+ col_type = col.get("type", "TEXT")
700
+ pk_str = " PRIMARY KEY" if col.get("is_primary_key") else ""
701
+ col_defs.append(f" {col_name} {col_type}{pk_str}")
702
+
703
+ for col in tbl.get("columns", []):
704
+ if col.get("is_foreign_key") and col.get("references_table") and col.get("references_column"):
705
+ ref_tbl = col["references_table"]
706
+ ref_col = col["references_column"]
707
+ col_defs.append(f" FOREIGN KEY ({col['name']}) REFERENCES {ref_tbl}({ref_col})")
708
+
709
+ lines.append(f"CREATE TABLE {tbl_name} (\n" + ",\n".join(col_defs) + "\n);")
710
+ return "\n\n".join(lines)
711
+
712
+ def verify_tables_in_db(self, schema_info: dict, db_path: str):
713
+ """
714
+ Verifies that extracted tables exist in the actual SQLite database.
715
+ """
716
+ conn = sqlite3.connect(db_path)
717
+ cursor = conn.cursor()
718
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
719
+ db_tables = {row[0].lower() for row in cursor.fetchall()}
720
+ conn.close()
721
+
722
+ valid_tables = []
723
+ for tbl in schema_info.get("tables", []):
724
+ tbl_name = tbl["name"].lower()
725
+ if tbl_name in db_tables:
726
+ valid_tables.append(tbl)
727
+ else:
728
+ print(f"[SchemaService] Table '{tbl_name}' extracted from ERD but not found in DB.")
729
+
730
+ schema_info["tables"] = valid_tables
731
+ valid_table_names = {t["name"].lower() for t in valid_tables}
732
+ valid_rels = []
733
+ for rel in schema_info.get("relationships", []):
734
+ if rel.get("from_table", "").lower() in valid_table_names and rel.get("to_table", "").lower() in valid_table_names:
735
+ valid_rels.append(rel)
736
+ schema_info["relationships"] = valid_rels
737
+
738
+ def build_db_from_sql(self, db_name: str, cleaned_sql: str) -> str:
739
+ """
740
+ Creates a new SQLite database from cleaned SQL statements (CREATE TABLE + INSERT INTO).
741
+ Returns the path to the created .db file.
742
+ """
743
+ db_path = os.path.join(config.SCHEMA_DB_DIR, f"{db_name}.db")
744
+ print(f"[SchemaService] Building database from SQL at: {db_path}")
745
+
746
+ conn = sqlite3.connect(db_path)
747
+ cursor = conn.cursor()
748
+
749
+ # Split by semicolons and execute each statement individually
750
+ statements = cleaned_sql.split(';')
751
+ executed = 0
752
+ failed = 0
753
+
754
+ for stmt in statements:
755
+ stmt = stmt.strip()
756
+ if not stmt:
757
+ continue
758
+ # Skip pure comments
759
+ lines_only = re.sub(r'--.*$', '', stmt, flags=re.MULTILINE).strip()
760
+ lines_only = re.sub(r'/\*.*?\*/', '', lines_only, flags=re.DOTALL).strip()
761
+ if not lines_only:
762
+ continue
763
+ try:
764
+ cursor.execute(stmt + ';')
765
+ executed += 1
766
+ except Exception as e:
767
+ failed += 1
768
+ print(f"[SchemaService] Skipped SQL statement (error: {e}): {stmt[:80]}...")
769
+
770
+ conn.commit()
771
+ conn.close()
772
+
773
+ print(f"[SchemaService] Database built: {executed} statements executed, {failed} skipped.")
774
+ return db_path
775
+
776
+ def register_schema_db(self, db_name: str, schema_sql_content: str = None, uploaded_db_path: str = None, erd_image_path: str = None) -> dict:
777
+ """
778
+ Registers a schema database using uploaded files (.sql, .db, and/or erd_image).
779
+ If uploaded_db_path is None and schema_sql_content is provided, the .db is auto-built from the SQL.
780
+ """
781
+ db_path = os.path.join(config.SCHEMA_DB_DIR, f"{db_name}.db")
782
+ sql_path = os.path.join(config.SCHEMA_DB_DIR, f"{db_name}.sql")
783
+ json_path = os.path.join(config.SCHEMA_DB_DIR, f"{db_name}.json")
784
+
785
+ # Clean T-SQL to SQLite before anything else
786
+ extra_relationships = []
787
+ if schema_sql_content:
788
+ schema_sql_content, extra_relationships = self.clean_tsql_to_sqlite(schema_sql_content)
789
+
790
+ # If a .db file was uploaded, copy it into place
791
+ if uploaded_db_path:
792
+ if os.path.abspath(uploaded_db_path) != os.path.abspath(db_path):
793
+ shutil.copy2(uploaded_db_path, db_path)
794
+ elif schema_sql_content:
795
+ # No .db uploaded — build it from the cleaned SQL
796
+ db_path = self.build_db_from_sql(db_name, schema_sql_content)
797
+ else:
798
+ raise ValueError("Either a .db file or a .sql file must be provided.")
799
+
800
+ schema_info = None
801
+
802
+ if erd_image_path:
803
+ if schema_sql_content:
804
+ parsed_schema = self.parse_schema_sql(schema_sql_content)
805
+ schema_info = self.enrich_relationships_from_erd_image(parsed_schema, erd_image_path)
806
+ else:
807
+ schema_info = self.extract_schema_from_erd_image(erd_image_path)
808
+ self.verify_tables_in_db(schema_info, db_path)
809
+ schema_sql_content = self.generate_sql_from_parsed_schema(schema_info)
810
+ else:
811
+ parsed_schema = self.parse_schema_sql(schema_sql_content)
812
+ parsed_schema["relationships"] = self.infer_cardinality_from_sql(parsed_schema["relationships"], parsed_schema["tables"])
813
+ schema_info = parsed_schema
814
+
815
+ # Merge extra_relationships from ALTER TABLE FK statements
816
+ for rel in extra_relationships:
817
+ already_exists = any(
818
+ r["from_table"] == rel["from_table"] and
819
+ r["from_column"] == rel["from_column"]
820
+ for r in schema_info["relationships"]
821
+ )
822
+ if not already_exists:
823
+ # Infer cardinality for the extra relationship
824
+ rel["cardinality"] = "1:N"
825
+ rel["from_participation"] = "partial"
826
+ rel["to_participation"] = "partial"
827
+ rel["relationship_name"] = None
828
+ schema_info["relationships"].append(rel)
829
+ # Also mark the column as a foreign key in the table definition
830
+ for tbl in schema_info.get("tables", []):
831
+ if tbl["name"] == rel["from_table"]:
832
+ for col in tbl.get("columns", []):
833
+ if col["name"] == rel["from_column"]:
834
+ col["is_foreign_key"] = True
835
+ col["references_table"] = rel["to_table"]
836
+ col["references_column"] = rel["to_column"]
837
+
838
+ with open(sql_path, "w", encoding="utf-8") as f:
839
+ f.write(schema_sql_content)
840
+
841
+ enriched_info = self.enrich_schema_db(db_name, schema_info, db_path)
842
+
843
+ with open(json_path, "w", encoding="utf-8") as f:
844
+ json.dump(enriched_info, f, indent=2)
845
+
846
+ return enriched_info
847
+
848
+ def get_schema_db_info(self, db_name: str) -> dict:
849
+ """
850
+ Retrieves the structured details of the schema. Reads from json cache if available.
851
+ """
852
+ sql_path = os.path.join(config.SCHEMA_DB_DIR, f"{db_name}.sql")
853
+ db_path = os.path.join(config.SCHEMA_DB_DIR, f"{db_name}.db")
854
+ json_path = os.path.join(config.SCHEMA_DB_DIR, f"{db_name}.json")
855
+
856
+ if os.path.exists(json_path):
857
+ try:
858
+ with open(json_path, "r", encoding="utf-8") as f:
859
+ return json.load(f)
860
+ except Exception as e:
861
+ print(f"[SchemaService] Error reading JSON cache: {e}. Falling back.")
862
+
863
+ if not os.path.exists(sql_path) or not os.path.exists(db_path):
864
+ raise FileNotFoundError(f"Database schema files for '{db_name}' do not exist.")
865
+
866
+ with open(sql_path, "r", encoding="utf-8") as f:
867
+ schema_sql_content = f.read()
868
+
869
+ schema_info = self.parse_schema_sql(schema_sql_content)
870
+ schema_info["relationships"] = self.infer_cardinality_from_sql(schema_info["relationships"], schema_info["tables"])
871
+
872
+ enriched_info = self.enrich_schema_db(db_name, schema_info, db_path)
873
+
874
+ try:
875
+ with open(json_path, "w", encoding="utf-8") as f:
876
+ json.dump(enriched_info, f, indent=2)
877
+ except Exception as e:
878
+ print(f"[SchemaService] Error caching schema JSON: {e}")
879
+
880
+ return enriched_info
881
+
882
+ def get_all_schema_datasets(self) -> List[dict]:
883
+ """
884
+ Lists all schema datasets, checking and initializing the sample e-commerce DB if needed.
885
+ """
886
+ os.makedirs(config.SCHEMA_DB_DIR, exist_ok=True)
887
+
888
+ sample_db_source = os.path.join(config.SAMPLE_DATA_DIR, "schema", "ecommerce.db")
889
+ sample_sql_source = os.path.join(config.SAMPLE_DATA_DIR, "schema", "ecommerce_schema.sql")
890
+
891
+ sample_db_dest = os.path.join(config.SCHEMA_DB_DIR, "ecommerce.db")
892
+ sample_sql_dest = os.path.join(config.SCHEMA_DB_DIR, "ecommerce.sql")
893
+
894
+ if not os.path.exists(sample_db_dest) and os.path.exists(sample_db_source):
895
+ print("[SchemaService] Copying ecommerce sample database to databases/schema/...")
896
+ shutil.copy2(sample_db_source, sample_db_dest)
897
+ if os.path.exists(sample_sql_source):
898
+ shutil.copy2(sample_sql_source, sample_sql_dest)
899
+
900
+ datasets = []
901
+ for filename in os.listdir(config.SCHEMA_DB_DIR):
902
+ if filename.endswith(".db"):
903
+ db_name = os.path.splitext(filename)[0]
904
+ try:
905
+ info = self.get_schema_db_info(db_name)
906
+ datasets.append({
907
+ "db_name": db_name,
908
+ "display_name": "E-Commerce Database" if db_name == "ecommerce" else db_name.replace("_", " ").capitalize(),
909
+ "description": "Relational e-commerce DB: customers, products, orders" if db_name == "ecommerce" else f"User-uploaded schema database: {db_name}",
910
+ "mode": "schema",
911
+ "is_sample": (db_name == "ecommerce"),
912
+ "total_tables": info["total_tables"],
913
+ "total_rows": info["total_rows"],
914
+ "tables": [t["name"] for t in info["tables"]],
915
+ "relationships": info["relationships"]
916
+ })
917
+ except Exception as e:
918
+ print(f"[SchemaService] Error loading dataset '{db_name}': {e}")
919
+
920
+ return datasets
921
+
922
+ # Singleton instance
923
+ schema_service = SchemaService()
backend/services/sql_service.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sqlite3
3
+ import re
4
+ import time
5
+ import pandas as pd
6
+ from typing import Dict, Any
7
+ from backend import config
8
+
9
+ class SQLService:
10
+ def __init__(self):
11
+ print("[SQLService] Initializing SQLService singleton...")
12
+
13
+ def validate_sql(self, sql: str) -> bool:
14
+ """
15
+ Validates that the SQL query is a SELECT statement and does not contain
16
+ forbidden modifying operations like INSERT, UPDATE, DELETE, DROP, CREATE, ALTER, EXEC.
17
+ """
18
+ print(f"[SQLService] Validating SQL: {sql}")
19
+ try:
20
+ if not sql or not isinstance(sql, str):
21
+ return False
22
+
23
+ sql_upper = sql.upper().strip()
24
+
25
+ # The SQL must be a SELECT statement
26
+ if "SELECT" not in sql_upper:
27
+ print("[SQLService] Validation failed: SQL does not contain SELECT keyword.")
28
+ return False
29
+
30
+ # Forbidden keywords pattern with word boundaries to avoid false positives (e.g. column name like 'create_date')
31
+ forbidden_pattern = r"\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|EXEC)\b"
32
+ if re.search(forbidden_pattern, sql_upper):
33
+ print("[SQLService] Validation failed: SQL contains forbidden modifying keywords.")
34
+ return False
35
+
36
+ return True
37
+ except Exception as e:
38
+ print(f"[SQLService] Error during SQL validation: {e}")
39
+ return False
40
+
41
+ def _clean_query_tsql_artifacts(self, sql: str) -> str:
42
+ """
43
+ Lightweight cleaner that removes accidental T-SQL syntax the LLM might
44
+ hallucinate when generating queries for a schema DB that was originally T-SQL.
45
+ Only applied to SELECT queries.
46
+ """
47
+ cleaned = sql
48
+
49
+ # Convert SELECT TOP N ... to SELECT ... LIMIT N
50
+ top_match = re.search(
51
+ r'\bSELECT\s+TOP\s+(\d+)\b',
52
+ cleaned, re.IGNORECASE
53
+ )
54
+ if top_match:
55
+ limit_n = top_match.group(1)
56
+ # Remove TOP N from SELECT
57
+ cleaned = re.sub(r'\bSELECT\s+TOP\s+\d+\b', 'SELECT', cleaned, flags=re.IGNORECASE)
58
+
59
+ # Check for semicolon at the end
60
+ has_semicolon = cleaned.strip().endswith(';')
61
+ cleaned_body = cleaned.strip()
62
+ if has_semicolon:
63
+ cleaned_body = cleaned_body[:-1].strip()
64
+
65
+ # Remove any existing LIMIT to avoid duplication
66
+ cleaned_body = re.sub(r'\bLIMIT\s+\d+\s*$', '', cleaned_body, flags=re.IGNORECASE).strip()
67
+
68
+ # Append LIMIT at the end
69
+ cleaned = f"{cleaned_body} LIMIT {limit_n}"
70
+ if has_semicolon:
71
+ cleaned += ";"
72
+
73
+ # Remove WITH (NOLOCK) hints
74
+ cleaned = re.sub(r'\bWITH\s*\(\s*NOLOCK\s*\)', '', cleaned, flags=re.IGNORECASE)
75
+
76
+ # Remove square brackets around identifiers: [EMPNO] → EMPNO
77
+ cleaned = re.sub(r'\[([^\]]+)\]', r'\1', cleaned)
78
+
79
+ # Clean up any double spaces left behind
80
+ cleaned = re.sub(r' +', ' ', cleaned).strip()
81
+
82
+ return cleaned
83
+
84
+ def execute_query(self, db_name: str, sql: str, mode: str = "csv") -> Dict[str, Any]:
85
+ """
86
+ Executes the provided SQL query against the specified SQLite database.
87
+ Validates the SQL first, limits output using pandas, measures execution time,
88
+ and translates SQLite/pandas dtypes to JSON-serializable Python objects.
89
+ """
90
+ print(f"[SQLService] Executing SQL on {db_name}.db (mode={mode}): {sql}")
91
+ try:
92
+ # 1. Validate SQL safety
93
+ if not self.validate_sql(sql):
94
+ raise ValueError("Dangerous or invalid SQL query. Only SELECT queries are allowed.")
95
+
96
+ # 1b. Clean any accidental T-SQL artifacts from LLM-generated queries
97
+ if mode == "schema":
98
+ sql = self._clean_query_tsql_artifacts(sql)
99
+ print(f"[SQLService] After T-SQL cleanup: {sql}")
100
+
101
+ if mode == "csv":
102
+ db_path = os.path.join(config.DB_DIR, f"{db_name}.db")
103
+ else:
104
+ db_path = os.path.join(config.SCHEMA_DB_DIR, f"{db_name}.db")
105
+
106
+ if not os.path.exists(db_path):
107
+ raise FileNotFoundError(f"Database {db_name}.db does not exist.")
108
+
109
+
110
+ conn = sqlite3.connect(db_path)
111
+
112
+ # 2. Execute query and measure time
113
+ start_time = time.perf_counter()
114
+ try:
115
+ df = pd.read_sql_query(sql, conn)
116
+ except Exception as sql_err:
117
+ conn.close()
118
+ print(f"[SQLService] SQLite Error: {sql_err}")
119
+ raise ValueError(f"SQLite error: {str(sql_err)}")
120
+
121
+ execution_time_ms = (time.perf_counter() - start_time) * 1000.0
122
+ conn.close()
123
+
124
+ # 3. Limit rows to MAX_ROWS_RETURNED
125
+ df_limited = df.head(config.MAX_ROWS_RETURNED)
126
+
127
+ # 4. Extract columns and rows, converting numpy/pandas NaN and types to native types
128
+ columns = df_limited.columns.tolist()
129
+ rows = []
130
+ for row in df_limited.itertuples(index=False):
131
+ clean_row = []
132
+ for val in row:
133
+ if pd.isna(val):
134
+ clean_row.append(None)
135
+ elif hasattr(val, "item") and not isinstance(val, (str, bytes)):
136
+ # Convert numpy/pandas numeric scalars to Python scalars
137
+ clean_row.append(val.item())
138
+ else:
139
+ clean_row.append(val)
140
+ rows.append(clean_row)
141
+
142
+ result = {
143
+ "columns": columns,
144
+ "rows": rows,
145
+ "row_count": len(rows),
146
+ "execution_time_ms": round(execution_time_ms, 2)
147
+ }
148
+ print(f"[SQLService] Query execution succeeded. Returned {result['row_count']} rows in {result['execution_time_ms']} ms.")
149
+ return result
150
+
151
+ except Exception as e:
152
+ print(f"[SQLService] Error during SQL execution: {e}")
153
+ raise ValueError(str(e))
154
+
155
+ # Singleton instance
156
+ sql_service = SQLService()
frontend/.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
frontend/README.md ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # React + Vite
2
+
3
+ This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4
+
5
+ Currently, two official plugins are available:
6
+
7
+ - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
8
+ - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
9
+
10
+ ## React Compiler
11
+
12
+ The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
13
+
14
+ ## Expanding the ESLint configuration
15
+
16
+ If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
frontend/eslint.config.js ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import js from '@eslint/js'
2
+ import globals from 'globals'
3
+ import reactHooks from 'eslint-plugin-react-hooks'
4
+ import reactRefresh from 'eslint-plugin-react-refresh'
5
+ import { defineConfig, globalIgnores } from 'eslint/config'
6
+
7
+ export default defineConfig([
8
+ globalIgnores(['dist']),
9
+ {
10
+ files: ['**/*.{js,jsx}'],
11
+ extends: [
12
+ js.configs.recommended,
13
+ reactHooks.configs.flat.recommended,
14
+ reactRefresh.configs.vite,
15
+ ],
16
+ languageOptions: {
17
+ globals: globals.browser,
18
+ parserOptions: { ecmaFeatures: { jsx: true } },
19
+ },
20
+ },
21
+ ])
frontend/index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Natural Language Data Analyst</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.jsx"></script>
12
+ </body>
13
+ </html>
frontend/package-lock.json ADDED
@@ -0,0 +1,2438 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "frontend",
3
+ "version": "0.0.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "frontend",
9
+ "version": "0.0.0",
10
+ "dependencies": {
11
+ "react": "^19.2.6",
12
+ "react-dom": "^19.2.6"
13
+ },
14
+ "devDependencies": {
15
+ "@eslint/js": "^10.0.1",
16
+ "@types/react": "^19.2.14",
17
+ "@types/react-dom": "^19.2.3",
18
+ "@vitejs/plugin-react": "^6.0.1",
19
+ "eslint": "^10.3.0",
20
+ "eslint-plugin-react-hooks": "^7.1.1",
21
+ "eslint-plugin-react-refresh": "^0.5.2",
22
+ "globals": "^17.6.0",
23
+ "vite": "^8.0.12"
24
+ }
25
+ },
26
+ "node_modules/@babel/code-frame": {
27
+ "version": "7.29.7",
28
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
29
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
30
+ "dev": true,
31
+ "license": "MIT",
32
+ "dependencies": {
33
+ "@babel/helper-validator-identifier": "^7.29.7",
34
+ "js-tokens": "^4.0.0",
35
+ "picocolors": "^1.1.1"
36
+ },
37
+ "engines": {
38
+ "node": ">=6.9.0"
39
+ }
40
+ },
41
+ "node_modules/@babel/compat-data": {
42
+ "version": "7.29.7",
43
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
44
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
45
+ "dev": true,
46
+ "license": "MIT",
47
+ "engines": {
48
+ "node": ">=6.9.0"
49
+ }
50
+ },
51
+ "node_modules/@babel/core": {
52
+ "version": "7.29.7",
53
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
54
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
55
+ "dev": true,
56
+ "license": "MIT",
57
+ "peer": true,
58
+ "dependencies": {
59
+ "@babel/code-frame": "^7.29.7",
60
+ "@babel/generator": "^7.29.7",
61
+ "@babel/helper-compilation-targets": "^7.29.7",
62
+ "@babel/helper-module-transforms": "^7.29.7",
63
+ "@babel/helpers": "^7.29.7",
64
+ "@babel/parser": "^7.29.7",
65
+ "@babel/template": "^7.29.7",
66
+ "@babel/traverse": "^7.29.7",
67
+ "@babel/types": "^7.29.7",
68
+ "@jridgewell/remapping": "^2.3.5",
69
+ "convert-source-map": "^2.0.0",
70
+ "debug": "^4.1.0",
71
+ "gensync": "^1.0.0-beta.2",
72
+ "json5": "^2.2.3",
73
+ "semver": "^6.3.1"
74
+ },
75
+ "engines": {
76
+ "node": ">=6.9.0"
77
+ },
78
+ "funding": {
79
+ "type": "opencollective",
80
+ "url": "https://opencollective.com/babel"
81
+ }
82
+ },
83
+ "node_modules/@babel/generator": {
84
+ "version": "7.29.7",
85
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
86
+ "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
87
+ "dev": true,
88
+ "license": "MIT",
89
+ "dependencies": {
90
+ "@babel/parser": "^7.29.7",
91
+ "@babel/types": "^7.29.7",
92
+ "@jridgewell/gen-mapping": "^0.3.12",
93
+ "@jridgewell/trace-mapping": "^0.3.28",
94
+ "jsesc": "^3.0.2"
95
+ },
96
+ "engines": {
97
+ "node": ">=6.9.0"
98
+ }
99
+ },
100
+ "node_modules/@babel/helper-compilation-targets": {
101
+ "version": "7.29.7",
102
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
103
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
104
+ "dev": true,
105
+ "license": "MIT",
106
+ "dependencies": {
107
+ "@babel/compat-data": "^7.29.7",
108
+ "@babel/helper-validator-option": "^7.29.7",
109
+ "browserslist": "^4.24.0",
110
+ "lru-cache": "^5.1.1",
111
+ "semver": "^6.3.1"
112
+ },
113
+ "engines": {
114
+ "node": ">=6.9.0"
115
+ }
116
+ },
117
+ "node_modules/@babel/helper-globals": {
118
+ "version": "7.29.7",
119
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
120
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
121
+ "dev": true,
122
+ "license": "MIT",
123
+ "engines": {
124
+ "node": ">=6.9.0"
125
+ }
126
+ },
127
+ "node_modules/@babel/helper-module-imports": {
128
+ "version": "7.29.7",
129
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
130
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
131
+ "dev": true,
132
+ "license": "MIT",
133
+ "dependencies": {
134
+ "@babel/traverse": "^7.29.7",
135
+ "@babel/types": "^7.29.7"
136
+ },
137
+ "engines": {
138
+ "node": ">=6.9.0"
139
+ }
140
+ },
141
+ "node_modules/@babel/helper-module-transforms": {
142
+ "version": "7.29.7",
143
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
144
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
145
+ "dev": true,
146
+ "license": "MIT",
147
+ "dependencies": {
148
+ "@babel/helper-module-imports": "^7.29.7",
149
+ "@babel/helper-validator-identifier": "^7.29.7",
150
+ "@babel/traverse": "^7.29.7"
151
+ },
152
+ "engines": {
153
+ "node": ">=6.9.0"
154
+ },
155
+ "peerDependencies": {
156
+ "@babel/core": "^7.0.0"
157
+ }
158
+ },
159
+ "node_modules/@babel/helper-string-parser": {
160
+ "version": "7.29.7",
161
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
162
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
163
+ "dev": true,
164
+ "license": "MIT",
165
+ "engines": {
166
+ "node": ">=6.9.0"
167
+ }
168
+ },
169
+ "node_modules/@babel/helper-validator-identifier": {
170
+ "version": "7.29.7",
171
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
172
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
173
+ "dev": true,
174
+ "license": "MIT",
175
+ "engines": {
176
+ "node": ">=6.9.0"
177
+ }
178
+ },
179
+ "node_modules/@babel/helper-validator-option": {
180
+ "version": "7.29.7",
181
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
182
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
183
+ "dev": true,
184
+ "license": "MIT",
185
+ "engines": {
186
+ "node": ">=6.9.0"
187
+ }
188
+ },
189
+ "node_modules/@babel/helpers": {
190
+ "version": "7.29.7",
191
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
192
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
193
+ "dev": true,
194
+ "license": "MIT",
195
+ "dependencies": {
196
+ "@babel/template": "^7.29.7",
197
+ "@babel/types": "^7.29.7"
198
+ },
199
+ "engines": {
200
+ "node": ">=6.9.0"
201
+ }
202
+ },
203
+ "node_modules/@babel/parser": {
204
+ "version": "7.29.7",
205
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
206
+ "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
207
+ "dev": true,
208
+ "license": "MIT",
209
+ "dependencies": {
210
+ "@babel/types": "^7.29.7"
211
+ },
212
+ "bin": {
213
+ "parser": "bin/babel-parser.js"
214
+ },
215
+ "engines": {
216
+ "node": ">=6.0.0"
217
+ }
218
+ },
219
+ "node_modules/@babel/template": {
220
+ "version": "7.29.7",
221
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
222
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
223
+ "dev": true,
224
+ "license": "MIT",
225
+ "dependencies": {
226
+ "@babel/code-frame": "^7.29.7",
227
+ "@babel/parser": "^7.29.7",
228
+ "@babel/types": "^7.29.7"
229
+ },
230
+ "engines": {
231
+ "node": ">=6.9.0"
232
+ }
233
+ },
234
+ "node_modules/@babel/traverse": {
235
+ "version": "7.29.7",
236
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
237
+ "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
238
+ "dev": true,
239
+ "license": "MIT",
240
+ "dependencies": {
241
+ "@babel/code-frame": "^7.29.7",
242
+ "@babel/generator": "^7.29.7",
243
+ "@babel/helper-globals": "^7.29.7",
244
+ "@babel/parser": "^7.29.7",
245
+ "@babel/template": "^7.29.7",
246
+ "@babel/types": "^7.29.7",
247
+ "debug": "^4.3.1"
248
+ },
249
+ "engines": {
250
+ "node": ">=6.9.0"
251
+ }
252
+ },
253
+ "node_modules/@babel/types": {
254
+ "version": "7.29.7",
255
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
256
+ "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
257
+ "dev": true,
258
+ "license": "MIT",
259
+ "dependencies": {
260
+ "@babel/helper-string-parser": "^7.29.7",
261
+ "@babel/helper-validator-identifier": "^7.29.7"
262
+ },
263
+ "engines": {
264
+ "node": ">=6.9.0"
265
+ }
266
+ },
267
+ "node_modules/@emnapi/core": {
268
+ "version": "1.10.0",
269
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
270
+ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
271
+ "dev": true,
272
+ "license": "MIT",
273
+ "optional": true,
274
+ "peer": true,
275
+ "dependencies": {
276
+ "@emnapi/wasi-threads": "1.2.1",
277
+ "tslib": "^2.4.0"
278
+ }
279
+ },
280
+ "node_modules/@emnapi/runtime": {
281
+ "version": "1.10.0",
282
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
283
+ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
284
+ "dev": true,
285
+ "license": "MIT",
286
+ "optional": true,
287
+ "peer": true,
288
+ "dependencies": {
289
+ "tslib": "^2.4.0"
290
+ }
291
+ },
292
+ "node_modules/@emnapi/wasi-threads": {
293
+ "version": "1.2.1",
294
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
295
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
296
+ "dev": true,
297
+ "license": "MIT",
298
+ "optional": true,
299
+ "dependencies": {
300
+ "tslib": "^2.4.0"
301
+ }
302
+ },
303
+ "node_modules/@eslint-community/eslint-utils": {
304
+ "version": "4.9.1",
305
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
306
+ "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
307
+ "dev": true,
308
+ "license": "MIT",
309
+ "dependencies": {
310
+ "eslint-visitor-keys": "^3.4.3"
311
+ },
312
+ "engines": {
313
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
314
+ },
315
+ "funding": {
316
+ "url": "https://opencollective.com/eslint"
317
+ },
318
+ "peerDependencies": {
319
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
320
+ }
321
+ },
322
+ "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
323
+ "version": "3.4.3",
324
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
325
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
326
+ "dev": true,
327
+ "license": "Apache-2.0",
328
+ "engines": {
329
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
330
+ },
331
+ "funding": {
332
+ "url": "https://opencollective.com/eslint"
333
+ }
334
+ },
335
+ "node_modules/@eslint-community/regexpp": {
336
+ "version": "4.12.2",
337
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
338
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
339
+ "dev": true,
340
+ "license": "MIT",
341
+ "engines": {
342
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
343
+ }
344
+ },
345
+ "node_modules/@eslint/config-array": {
346
+ "version": "0.23.5",
347
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz",
348
+ "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==",
349
+ "dev": true,
350
+ "license": "Apache-2.0",
351
+ "dependencies": {
352
+ "@eslint/object-schema": "^3.0.5",
353
+ "debug": "^4.3.1",
354
+ "minimatch": "^10.2.4"
355
+ },
356
+ "engines": {
357
+ "node": "^20.19.0 || ^22.13.0 || >=24"
358
+ }
359
+ },
360
+ "node_modules/@eslint/config-helpers": {
361
+ "version": "0.6.0",
362
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz",
363
+ "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==",
364
+ "dev": true,
365
+ "license": "Apache-2.0",
366
+ "dependencies": {
367
+ "@eslint/core": "^1.2.1"
368
+ },
369
+ "engines": {
370
+ "node": "^20.19.0 || ^22.13.0 || >=24"
371
+ }
372
+ },
373
+ "node_modules/@eslint/core": {
374
+ "version": "1.2.1",
375
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz",
376
+ "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==",
377
+ "dev": true,
378
+ "license": "Apache-2.0",
379
+ "dependencies": {
380
+ "@types/json-schema": "^7.0.15"
381
+ },
382
+ "engines": {
383
+ "node": "^20.19.0 || ^22.13.0 || >=24"
384
+ }
385
+ },
386
+ "node_modules/@eslint/js": {
387
+ "version": "10.0.1",
388
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz",
389
+ "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==",
390
+ "dev": true,
391
+ "license": "MIT",
392
+ "engines": {
393
+ "node": "^20.19.0 || ^22.13.0 || >=24"
394
+ },
395
+ "funding": {
396
+ "url": "https://eslint.org/donate"
397
+ },
398
+ "peerDependencies": {
399
+ "eslint": "^10.0.0"
400
+ },
401
+ "peerDependenciesMeta": {
402
+ "eslint": {
403
+ "optional": true
404
+ }
405
+ }
406
+ },
407
+ "node_modules/@eslint/object-schema": {
408
+ "version": "3.0.5",
409
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz",
410
+ "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==",
411
+ "dev": true,
412
+ "license": "Apache-2.0",
413
+ "engines": {
414
+ "node": "^20.19.0 || ^22.13.0 || >=24"
415
+ }
416
+ },
417
+ "node_modules/@eslint/plugin-kit": {
418
+ "version": "0.7.2",
419
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz",
420
+ "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==",
421
+ "dev": true,
422
+ "license": "Apache-2.0",
423
+ "dependencies": {
424
+ "@eslint/core": "^1.2.1",
425
+ "levn": "^0.4.1"
426
+ },
427
+ "engines": {
428
+ "node": "^20.19.0 || ^22.13.0 || >=24"
429
+ }
430
+ },
431
+ "node_modules/@humanfs/core": {
432
+ "version": "0.19.2",
433
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
434
+ "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
435
+ "dev": true,
436
+ "license": "Apache-2.0",
437
+ "dependencies": {
438
+ "@humanfs/types": "^0.15.0"
439
+ },
440
+ "engines": {
441
+ "node": ">=18.18.0"
442
+ }
443
+ },
444
+ "node_modules/@humanfs/node": {
445
+ "version": "0.16.8",
446
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
447
+ "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
448
+ "dev": true,
449
+ "license": "Apache-2.0",
450
+ "dependencies": {
451
+ "@humanfs/core": "^0.19.2",
452
+ "@humanfs/types": "^0.15.0",
453
+ "@humanwhocodes/retry": "^0.4.0"
454
+ },
455
+ "engines": {
456
+ "node": ">=18.18.0"
457
+ }
458
+ },
459
+ "node_modules/@humanfs/types": {
460
+ "version": "0.15.0",
461
+ "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
462
+ "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
463
+ "dev": true,
464
+ "license": "Apache-2.0",
465
+ "engines": {
466
+ "node": ">=18.18.0"
467
+ }
468
+ },
469
+ "node_modules/@humanwhocodes/module-importer": {
470
+ "version": "1.0.1",
471
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
472
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
473
+ "dev": true,
474
+ "license": "Apache-2.0",
475
+ "engines": {
476
+ "node": ">=12.22"
477
+ },
478
+ "funding": {
479
+ "type": "github",
480
+ "url": "https://github.com/sponsors/nzakas"
481
+ }
482
+ },
483
+ "node_modules/@humanwhocodes/retry": {
484
+ "version": "0.4.3",
485
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
486
+ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
487
+ "dev": true,
488
+ "license": "Apache-2.0",
489
+ "engines": {
490
+ "node": ">=18.18"
491
+ },
492
+ "funding": {
493
+ "type": "github",
494
+ "url": "https://github.com/sponsors/nzakas"
495
+ }
496
+ },
497
+ "node_modules/@jridgewell/gen-mapping": {
498
+ "version": "0.3.13",
499
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
500
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
501
+ "dev": true,
502
+ "license": "MIT",
503
+ "dependencies": {
504
+ "@jridgewell/sourcemap-codec": "^1.5.0",
505
+ "@jridgewell/trace-mapping": "^0.3.24"
506
+ }
507
+ },
508
+ "node_modules/@jridgewell/remapping": {
509
+ "version": "2.3.5",
510
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
511
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
512
+ "dev": true,
513
+ "license": "MIT",
514
+ "dependencies": {
515
+ "@jridgewell/gen-mapping": "^0.3.5",
516
+ "@jridgewell/trace-mapping": "^0.3.24"
517
+ }
518
+ },
519
+ "node_modules/@jridgewell/resolve-uri": {
520
+ "version": "3.1.2",
521
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
522
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
523
+ "dev": true,
524
+ "license": "MIT",
525
+ "engines": {
526
+ "node": ">=6.0.0"
527
+ }
528
+ },
529
+ "node_modules/@jridgewell/sourcemap-codec": {
530
+ "version": "1.5.5",
531
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
532
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
533
+ "dev": true,
534
+ "license": "MIT"
535
+ },
536
+ "node_modules/@jridgewell/trace-mapping": {
537
+ "version": "0.3.31",
538
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
539
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
540
+ "dev": true,
541
+ "license": "MIT",
542
+ "dependencies": {
543
+ "@jridgewell/resolve-uri": "^3.1.0",
544
+ "@jridgewell/sourcemap-codec": "^1.4.14"
545
+ }
546
+ },
547
+ "node_modules/@napi-rs/wasm-runtime": {
548
+ "version": "1.1.5",
549
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
550
+ "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==",
551
+ "dev": true,
552
+ "license": "MIT",
553
+ "optional": true,
554
+ "dependencies": {
555
+ "@tybys/wasm-util": "^0.10.2"
556
+ },
557
+ "funding": {
558
+ "type": "github",
559
+ "url": "https://github.com/sponsors/Brooooooklyn"
560
+ },
561
+ "peerDependencies": {
562
+ "@emnapi/core": "^1.7.1",
563
+ "@emnapi/runtime": "^1.7.1"
564
+ }
565
+ },
566
+ "node_modules/@oxc-project/types": {
567
+ "version": "0.133.0",
568
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
569
+ "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==",
570
+ "dev": true,
571
+ "license": "MIT",
572
+ "funding": {
573
+ "url": "https://github.com/sponsors/Boshen"
574
+ }
575
+ },
576
+ "node_modules/@rolldown/binding-android-arm64": {
577
+ "version": "1.0.3",
578
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
579
+ "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==",
580
+ "cpu": [
581
+ "arm64"
582
+ ],
583
+ "dev": true,
584
+ "license": "MIT",
585
+ "optional": true,
586
+ "os": [
587
+ "android"
588
+ ],
589
+ "engines": {
590
+ "node": "^20.19.0 || >=22.12.0"
591
+ }
592
+ },
593
+ "node_modules/@rolldown/binding-darwin-arm64": {
594
+ "version": "1.0.3",
595
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz",
596
+ "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==",
597
+ "cpu": [
598
+ "arm64"
599
+ ],
600
+ "dev": true,
601
+ "license": "MIT",
602
+ "optional": true,
603
+ "os": [
604
+ "darwin"
605
+ ],
606
+ "engines": {
607
+ "node": "^20.19.0 || >=22.12.0"
608
+ }
609
+ },
610
+ "node_modules/@rolldown/binding-darwin-x64": {
611
+ "version": "1.0.3",
612
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz",
613
+ "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==",
614
+ "cpu": [
615
+ "x64"
616
+ ],
617
+ "dev": true,
618
+ "license": "MIT",
619
+ "optional": true,
620
+ "os": [
621
+ "darwin"
622
+ ],
623
+ "engines": {
624
+ "node": "^20.19.0 || >=22.12.0"
625
+ }
626
+ },
627
+ "node_modules/@rolldown/binding-freebsd-x64": {
628
+ "version": "1.0.3",
629
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz",
630
+ "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==",
631
+ "cpu": [
632
+ "x64"
633
+ ],
634
+ "dev": true,
635
+ "license": "MIT",
636
+ "optional": true,
637
+ "os": [
638
+ "freebsd"
639
+ ],
640
+ "engines": {
641
+ "node": "^20.19.0 || >=22.12.0"
642
+ }
643
+ },
644
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
645
+ "version": "1.0.3",
646
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz",
647
+ "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==",
648
+ "cpu": [
649
+ "arm"
650
+ ],
651
+ "dev": true,
652
+ "license": "MIT",
653
+ "optional": true,
654
+ "os": [
655
+ "linux"
656
+ ],
657
+ "engines": {
658
+ "node": "^20.19.0 || >=22.12.0"
659
+ }
660
+ },
661
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
662
+ "version": "1.0.3",
663
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz",
664
+ "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==",
665
+ "cpu": [
666
+ "arm64"
667
+ ],
668
+ "dev": true,
669
+ "license": "MIT",
670
+ "optional": true,
671
+ "os": [
672
+ "linux"
673
+ ],
674
+ "engines": {
675
+ "node": "^20.19.0 || >=22.12.0"
676
+ }
677
+ },
678
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
679
+ "version": "1.0.3",
680
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz",
681
+ "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==",
682
+ "cpu": [
683
+ "arm64"
684
+ ],
685
+ "dev": true,
686
+ "license": "MIT",
687
+ "optional": true,
688
+ "os": [
689
+ "linux"
690
+ ],
691
+ "engines": {
692
+ "node": "^20.19.0 || >=22.12.0"
693
+ }
694
+ },
695
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
696
+ "version": "1.0.3",
697
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz",
698
+ "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==",
699
+ "cpu": [
700
+ "ppc64"
701
+ ],
702
+ "dev": true,
703
+ "license": "MIT",
704
+ "optional": true,
705
+ "os": [
706
+ "linux"
707
+ ],
708
+ "engines": {
709
+ "node": "^20.19.0 || >=22.12.0"
710
+ }
711
+ },
712
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
713
+ "version": "1.0.3",
714
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz",
715
+ "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==",
716
+ "cpu": [
717
+ "s390x"
718
+ ],
719
+ "dev": true,
720
+ "license": "MIT",
721
+ "optional": true,
722
+ "os": [
723
+ "linux"
724
+ ],
725
+ "engines": {
726
+ "node": "^20.19.0 || >=22.12.0"
727
+ }
728
+ },
729
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
730
+ "version": "1.0.3",
731
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz",
732
+ "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==",
733
+ "cpu": [
734
+ "x64"
735
+ ],
736
+ "dev": true,
737
+ "license": "MIT",
738
+ "optional": true,
739
+ "os": [
740
+ "linux"
741
+ ],
742
+ "engines": {
743
+ "node": "^20.19.0 || >=22.12.0"
744
+ }
745
+ },
746
+ "node_modules/@rolldown/binding-linux-x64-musl": {
747
+ "version": "1.0.3",
748
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz",
749
+ "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==",
750
+ "cpu": [
751
+ "x64"
752
+ ],
753
+ "dev": true,
754
+ "license": "MIT",
755
+ "optional": true,
756
+ "os": [
757
+ "linux"
758
+ ],
759
+ "engines": {
760
+ "node": "^20.19.0 || >=22.12.0"
761
+ }
762
+ },
763
+ "node_modules/@rolldown/binding-openharmony-arm64": {
764
+ "version": "1.0.3",
765
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz",
766
+ "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==",
767
+ "cpu": [
768
+ "arm64"
769
+ ],
770
+ "dev": true,
771
+ "license": "MIT",
772
+ "optional": true,
773
+ "os": [
774
+ "openharmony"
775
+ ],
776
+ "engines": {
777
+ "node": "^20.19.0 || >=22.12.0"
778
+ }
779
+ },
780
+ "node_modules/@rolldown/binding-wasm32-wasi": {
781
+ "version": "1.0.3",
782
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz",
783
+ "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==",
784
+ "cpu": [
785
+ "wasm32"
786
+ ],
787
+ "dev": true,
788
+ "license": "MIT",
789
+ "optional": true,
790
+ "dependencies": {
791
+ "@emnapi/core": "1.10.0",
792
+ "@emnapi/runtime": "1.10.0",
793
+ "@napi-rs/wasm-runtime": "^1.1.4"
794
+ },
795
+ "engines": {
796
+ "node": "^20.19.0 || >=22.12.0"
797
+ }
798
+ },
799
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
800
+ "version": "1.0.3",
801
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
802
+ "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==",
803
+ "cpu": [
804
+ "arm64"
805
+ ],
806
+ "dev": true,
807
+ "license": "MIT",
808
+ "optional": true,
809
+ "os": [
810
+ "win32"
811
+ ],
812
+ "engines": {
813
+ "node": "^20.19.0 || >=22.12.0"
814
+ }
815
+ },
816
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
817
+ "version": "1.0.3",
818
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz",
819
+ "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==",
820
+ "cpu": [
821
+ "x64"
822
+ ],
823
+ "dev": true,
824
+ "license": "MIT",
825
+ "optional": true,
826
+ "os": [
827
+ "win32"
828
+ ],
829
+ "engines": {
830
+ "node": "^20.19.0 || >=22.12.0"
831
+ }
832
+ },
833
+ "node_modules/@rolldown/pluginutils": {
834
+ "version": "1.0.1",
835
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
836
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
837
+ "dev": true,
838
+ "license": "MIT"
839
+ },
840
+ "node_modules/@tybys/wasm-util": {
841
+ "version": "0.10.2",
842
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
843
+ "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
844
+ "dev": true,
845
+ "license": "MIT",
846
+ "optional": true,
847
+ "dependencies": {
848
+ "tslib": "^2.4.0"
849
+ }
850
+ },
851
+ "node_modules/@types/esrecurse": {
852
+ "version": "4.3.1",
853
+ "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
854
+ "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
855
+ "dev": true,
856
+ "license": "MIT"
857
+ },
858
+ "node_modules/@types/estree": {
859
+ "version": "1.0.9",
860
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
861
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
862
+ "dev": true,
863
+ "license": "MIT"
864
+ },
865
+ "node_modules/@types/json-schema": {
866
+ "version": "7.0.15",
867
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
868
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
869
+ "dev": true,
870
+ "license": "MIT"
871
+ },
872
+ "node_modules/@types/react": {
873
+ "version": "19.2.17",
874
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
875
+ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
876
+ "dev": true,
877
+ "license": "MIT",
878
+ "peer": true,
879
+ "dependencies": {
880
+ "csstype": "^3.2.2"
881
+ }
882
+ },
883
+ "node_modules/@types/react-dom": {
884
+ "version": "19.2.3",
885
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
886
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
887
+ "dev": true,
888
+ "license": "MIT",
889
+ "peerDependencies": {
890
+ "@types/react": "^19.2.0"
891
+ }
892
+ },
893
+ "node_modules/@vitejs/plugin-react": {
894
+ "version": "6.0.2",
895
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz",
896
+ "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==",
897
+ "dev": true,
898
+ "license": "MIT",
899
+ "dependencies": {
900
+ "@rolldown/pluginutils": "^1.0.0"
901
+ },
902
+ "engines": {
903
+ "node": "^20.19.0 || >=22.12.0"
904
+ },
905
+ "peerDependencies": {
906
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
907
+ "babel-plugin-react-compiler": "^1.0.0",
908
+ "vite": "^8.0.0"
909
+ },
910
+ "peerDependenciesMeta": {
911
+ "@rolldown/plugin-babel": {
912
+ "optional": true
913
+ },
914
+ "babel-plugin-react-compiler": {
915
+ "optional": true
916
+ }
917
+ }
918
+ },
919
+ "node_modules/acorn": {
920
+ "version": "8.17.0",
921
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
922
+ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
923
+ "dev": true,
924
+ "license": "MIT",
925
+ "peer": true,
926
+ "bin": {
927
+ "acorn": "bin/acorn"
928
+ },
929
+ "engines": {
930
+ "node": ">=0.4.0"
931
+ }
932
+ },
933
+ "node_modules/acorn-jsx": {
934
+ "version": "5.3.2",
935
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
936
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
937
+ "dev": true,
938
+ "license": "MIT",
939
+ "peerDependencies": {
940
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
941
+ }
942
+ },
943
+ "node_modules/ajv": {
944
+ "version": "6.15.0",
945
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
946
+ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
947
+ "dev": true,
948
+ "license": "MIT",
949
+ "dependencies": {
950
+ "fast-deep-equal": "^3.1.1",
951
+ "fast-json-stable-stringify": "^2.0.0",
952
+ "json-schema-traverse": "^0.4.1",
953
+ "uri-js": "^4.2.2"
954
+ },
955
+ "funding": {
956
+ "type": "github",
957
+ "url": "https://github.com/sponsors/epoberezkin"
958
+ }
959
+ },
960
+ "node_modules/balanced-match": {
961
+ "version": "4.0.4",
962
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
963
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
964
+ "dev": true,
965
+ "license": "MIT",
966
+ "engines": {
967
+ "node": "18 || 20 || >=22"
968
+ }
969
+ },
970
+ "node_modules/baseline-browser-mapping": {
971
+ "version": "2.10.37",
972
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz",
973
+ "integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==",
974
+ "dev": true,
975
+ "license": "Apache-2.0",
976
+ "bin": {
977
+ "baseline-browser-mapping": "dist/cli.cjs"
978
+ },
979
+ "engines": {
980
+ "node": ">=6.0.0"
981
+ }
982
+ },
983
+ "node_modules/brace-expansion": {
984
+ "version": "5.0.6",
985
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
986
+ "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
987
+ "dev": true,
988
+ "license": "MIT",
989
+ "dependencies": {
990
+ "balanced-match": "^4.0.2"
991
+ },
992
+ "engines": {
993
+ "node": "18 || 20 || >=22"
994
+ }
995
+ },
996
+ "node_modules/browserslist": {
997
+ "version": "4.28.2",
998
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
999
+ "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
1000
+ "dev": true,
1001
+ "funding": [
1002
+ {
1003
+ "type": "opencollective",
1004
+ "url": "https://opencollective.com/browserslist"
1005
+ },
1006
+ {
1007
+ "type": "tidelift",
1008
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
1009
+ },
1010
+ {
1011
+ "type": "github",
1012
+ "url": "https://github.com/sponsors/ai"
1013
+ }
1014
+ ],
1015
+ "license": "MIT",
1016
+ "peer": true,
1017
+ "dependencies": {
1018
+ "baseline-browser-mapping": "^2.10.12",
1019
+ "caniuse-lite": "^1.0.30001782",
1020
+ "electron-to-chromium": "^1.5.328",
1021
+ "node-releases": "^2.0.36",
1022
+ "update-browserslist-db": "^1.2.3"
1023
+ },
1024
+ "bin": {
1025
+ "browserslist": "cli.js"
1026
+ },
1027
+ "engines": {
1028
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
1029
+ }
1030
+ },
1031
+ "node_modules/caniuse-lite": {
1032
+ "version": "1.0.30001799",
1033
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
1034
+ "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
1035
+ "dev": true,
1036
+ "funding": [
1037
+ {
1038
+ "type": "opencollective",
1039
+ "url": "https://opencollective.com/browserslist"
1040
+ },
1041
+ {
1042
+ "type": "tidelift",
1043
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
1044
+ },
1045
+ {
1046
+ "type": "github",
1047
+ "url": "https://github.com/sponsors/ai"
1048
+ }
1049
+ ],
1050
+ "license": "CC-BY-4.0"
1051
+ },
1052
+ "node_modules/convert-source-map": {
1053
+ "version": "2.0.0",
1054
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
1055
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
1056
+ "dev": true,
1057
+ "license": "MIT"
1058
+ },
1059
+ "node_modules/cross-spawn": {
1060
+ "version": "7.0.6",
1061
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
1062
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
1063
+ "dev": true,
1064
+ "license": "MIT",
1065
+ "dependencies": {
1066
+ "path-key": "^3.1.0",
1067
+ "shebang-command": "^2.0.0",
1068
+ "which": "^2.0.1"
1069
+ },
1070
+ "engines": {
1071
+ "node": ">= 8"
1072
+ }
1073
+ },
1074
+ "node_modules/csstype": {
1075
+ "version": "3.2.3",
1076
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
1077
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
1078
+ "dev": true,
1079
+ "license": "MIT"
1080
+ },
1081
+ "node_modules/debug": {
1082
+ "version": "4.4.3",
1083
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
1084
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
1085
+ "dev": true,
1086
+ "license": "MIT",
1087
+ "dependencies": {
1088
+ "ms": "^2.1.3"
1089
+ },
1090
+ "engines": {
1091
+ "node": ">=6.0"
1092
+ },
1093
+ "peerDependenciesMeta": {
1094
+ "supports-color": {
1095
+ "optional": true
1096
+ }
1097
+ }
1098
+ },
1099
+ "node_modules/deep-is": {
1100
+ "version": "0.1.4",
1101
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
1102
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
1103
+ "dev": true,
1104
+ "license": "MIT"
1105
+ },
1106
+ "node_modules/detect-libc": {
1107
+ "version": "2.1.2",
1108
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
1109
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
1110
+ "dev": true,
1111
+ "license": "Apache-2.0",
1112
+ "engines": {
1113
+ "node": ">=8"
1114
+ }
1115
+ },
1116
+ "node_modules/electron-to-chromium": {
1117
+ "version": "1.5.374",
1118
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.374.tgz",
1119
+ "integrity": "sha512-HCF5i7izveksHSGqa7mhDh6tr3Uz9Dar2RAjwuh69bw3QGPVObjQIgLwQWeO/Rxp9/r0KdboKy9RbpQDl97fjg==",
1120
+ "dev": true,
1121
+ "license": "ISC"
1122
+ },
1123
+ "node_modules/escalade": {
1124
+ "version": "3.2.0",
1125
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
1126
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
1127
+ "dev": true,
1128
+ "license": "MIT",
1129
+ "engines": {
1130
+ "node": ">=6"
1131
+ }
1132
+ },
1133
+ "node_modules/escape-string-regexp": {
1134
+ "version": "4.0.0",
1135
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
1136
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
1137
+ "dev": true,
1138
+ "license": "MIT",
1139
+ "engines": {
1140
+ "node": ">=10"
1141
+ },
1142
+ "funding": {
1143
+ "url": "https://github.com/sponsors/sindresorhus"
1144
+ }
1145
+ },
1146
+ "node_modules/eslint": {
1147
+ "version": "10.5.0",
1148
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.5.0.tgz",
1149
+ "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==",
1150
+ "dev": true,
1151
+ "license": "MIT",
1152
+ "peer": true,
1153
+ "workspaces": [
1154
+ "packages/*"
1155
+ ],
1156
+ "dependencies": {
1157
+ "@eslint-community/eslint-utils": "^4.8.0",
1158
+ "@eslint-community/regexpp": "^4.12.2",
1159
+ "@eslint/config-array": "^0.23.5",
1160
+ "@eslint/config-helpers": "^0.6.0",
1161
+ "@eslint/core": "^1.2.1",
1162
+ "@eslint/plugin-kit": "^0.7.2",
1163
+ "@humanfs/node": "^0.16.6",
1164
+ "@humanwhocodes/module-importer": "^1.0.1",
1165
+ "@humanwhocodes/retry": "^0.4.2",
1166
+ "@types/estree": "^1.0.6",
1167
+ "ajv": "^6.14.0",
1168
+ "cross-spawn": "^7.0.6",
1169
+ "debug": "^4.3.2",
1170
+ "escape-string-regexp": "^4.0.0",
1171
+ "eslint-scope": "^9.1.2",
1172
+ "eslint-visitor-keys": "^5.0.1",
1173
+ "espree": "^11.2.0",
1174
+ "esquery": "^1.7.0",
1175
+ "esutils": "^2.0.2",
1176
+ "fast-deep-equal": "^3.1.3",
1177
+ "file-entry-cache": "^8.0.0",
1178
+ "find-up": "^5.0.0",
1179
+ "glob-parent": "^6.0.2",
1180
+ "ignore": "^5.2.0",
1181
+ "imurmurhash": "^0.1.4",
1182
+ "is-glob": "^4.0.0",
1183
+ "json-stable-stringify-without-jsonify": "^1.0.1",
1184
+ "minimatch": "^10.2.4",
1185
+ "natural-compare": "^1.4.0",
1186
+ "optionator": "^0.9.3"
1187
+ },
1188
+ "bin": {
1189
+ "eslint": "bin/eslint.js"
1190
+ },
1191
+ "engines": {
1192
+ "node": "^20.19.0 || ^22.13.0 || >=24"
1193
+ },
1194
+ "funding": {
1195
+ "url": "https://eslint.org/donate"
1196
+ },
1197
+ "peerDependencies": {
1198
+ "jiti": "*"
1199
+ },
1200
+ "peerDependenciesMeta": {
1201
+ "jiti": {
1202
+ "optional": true
1203
+ }
1204
+ }
1205
+ },
1206
+ "node_modules/eslint-plugin-react-hooks": {
1207
+ "version": "7.1.1",
1208
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz",
1209
+ "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==",
1210
+ "dev": true,
1211
+ "license": "MIT",
1212
+ "dependencies": {
1213
+ "@babel/core": "^7.24.4",
1214
+ "@babel/parser": "^7.24.4",
1215
+ "hermes-parser": "^0.25.1",
1216
+ "zod": "^3.25.0 || ^4.0.0",
1217
+ "zod-validation-error": "^3.5.0 || ^4.0.0"
1218
+ },
1219
+ "engines": {
1220
+ "node": ">=18"
1221
+ },
1222
+ "peerDependencies": {
1223
+ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0"
1224
+ }
1225
+ },
1226
+ "node_modules/eslint-plugin-react-refresh": {
1227
+ "version": "0.5.3",
1228
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.3.tgz",
1229
+ "integrity": "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==",
1230
+ "dev": true,
1231
+ "license": "MIT",
1232
+ "peerDependencies": {
1233
+ "eslint": "^9 || ^10"
1234
+ }
1235
+ },
1236
+ "node_modules/eslint-scope": {
1237
+ "version": "9.1.2",
1238
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
1239
+ "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
1240
+ "dev": true,
1241
+ "license": "BSD-2-Clause",
1242
+ "dependencies": {
1243
+ "@types/esrecurse": "^4.3.1",
1244
+ "@types/estree": "^1.0.8",
1245
+ "esrecurse": "^4.3.0",
1246
+ "estraverse": "^5.2.0"
1247
+ },
1248
+ "engines": {
1249
+ "node": "^20.19.0 || ^22.13.0 || >=24"
1250
+ },
1251
+ "funding": {
1252
+ "url": "https://opencollective.com/eslint"
1253
+ }
1254
+ },
1255
+ "node_modules/eslint-visitor-keys": {
1256
+ "version": "5.0.1",
1257
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
1258
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
1259
+ "dev": true,
1260
+ "license": "Apache-2.0",
1261
+ "engines": {
1262
+ "node": "^20.19.0 || ^22.13.0 || >=24"
1263
+ },
1264
+ "funding": {
1265
+ "url": "https://opencollective.com/eslint"
1266
+ }
1267
+ },
1268
+ "node_modules/espree": {
1269
+ "version": "11.2.0",
1270
+ "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
1271
+ "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
1272
+ "dev": true,
1273
+ "license": "BSD-2-Clause",
1274
+ "dependencies": {
1275
+ "acorn": "^8.16.0",
1276
+ "acorn-jsx": "^5.3.2",
1277
+ "eslint-visitor-keys": "^5.0.1"
1278
+ },
1279
+ "engines": {
1280
+ "node": "^20.19.0 || ^22.13.0 || >=24"
1281
+ },
1282
+ "funding": {
1283
+ "url": "https://opencollective.com/eslint"
1284
+ }
1285
+ },
1286
+ "node_modules/esquery": {
1287
+ "version": "1.7.0",
1288
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
1289
+ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
1290
+ "dev": true,
1291
+ "license": "BSD-3-Clause",
1292
+ "dependencies": {
1293
+ "estraverse": "^5.1.0"
1294
+ },
1295
+ "engines": {
1296
+ "node": ">=0.10"
1297
+ }
1298
+ },
1299
+ "node_modules/esrecurse": {
1300
+ "version": "4.3.0",
1301
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
1302
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
1303
+ "dev": true,
1304
+ "license": "BSD-2-Clause",
1305
+ "dependencies": {
1306
+ "estraverse": "^5.2.0"
1307
+ },
1308
+ "engines": {
1309
+ "node": ">=4.0"
1310
+ }
1311
+ },
1312
+ "node_modules/estraverse": {
1313
+ "version": "5.3.0",
1314
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
1315
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
1316
+ "dev": true,
1317
+ "license": "BSD-2-Clause",
1318
+ "engines": {
1319
+ "node": ">=4.0"
1320
+ }
1321
+ },
1322
+ "node_modules/esutils": {
1323
+ "version": "2.0.3",
1324
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
1325
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
1326
+ "dev": true,
1327
+ "license": "BSD-2-Clause",
1328
+ "engines": {
1329
+ "node": ">=0.10.0"
1330
+ }
1331
+ },
1332
+ "node_modules/fast-deep-equal": {
1333
+ "version": "3.1.3",
1334
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
1335
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
1336
+ "dev": true,
1337
+ "license": "MIT"
1338
+ },
1339
+ "node_modules/fast-json-stable-stringify": {
1340
+ "version": "2.1.0",
1341
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
1342
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
1343
+ "dev": true,
1344
+ "license": "MIT"
1345
+ },
1346
+ "node_modules/fast-levenshtein": {
1347
+ "version": "2.0.6",
1348
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
1349
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
1350
+ "dev": true,
1351
+ "license": "MIT"
1352
+ },
1353
+ "node_modules/fdir": {
1354
+ "version": "6.5.0",
1355
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
1356
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
1357
+ "dev": true,
1358
+ "license": "MIT",
1359
+ "engines": {
1360
+ "node": ">=12.0.0"
1361
+ },
1362
+ "peerDependencies": {
1363
+ "picomatch": "^3 || ^4"
1364
+ },
1365
+ "peerDependenciesMeta": {
1366
+ "picomatch": {
1367
+ "optional": true
1368
+ }
1369
+ }
1370
+ },
1371
+ "node_modules/file-entry-cache": {
1372
+ "version": "8.0.0",
1373
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
1374
+ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
1375
+ "dev": true,
1376
+ "license": "MIT",
1377
+ "dependencies": {
1378
+ "flat-cache": "^4.0.0"
1379
+ },
1380
+ "engines": {
1381
+ "node": ">=16.0.0"
1382
+ }
1383
+ },
1384
+ "node_modules/find-up": {
1385
+ "version": "5.0.0",
1386
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
1387
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
1388
+ "dev": true,
1389
+ "license": "MIT",
1390
+ "dependencies": {
1391
+ "locate-path": "^6.0.0",
1392
+ "path-exists": "^4.0.0"
1393
+ },
1394
+ "engines": {
1395
+ "node": ">=10"
1396
+ },
1397
+ "funding": {
1398
+ "url": "https://github.com/sponsors/sindresorhus"
1399
+ }
1400
+ },
1401
+ "node_modules/flat-cache": {
1402
+ "version": "4.0.1",
1403
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
1404
+ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
1405
+ "dev": true,
1406
+ "license": "MIT",
1407
+ "dependencies": {
1408
+ "flatted": "^3.2.9",
1409
+ "keyv": "^4.5.4"
1410
+ },
1411
+ "engines": {
1412
+ "node": ">=16"
1413
+ }
1414
+ },
1415
+ "node_modules/flatted": {
1416
+ "version": "3.4.2",
1417
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
1418
+ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
1419
+ "dev": true,
1420
+ "license": "ISC"
1421
+ },
1422
+ "node_modules/fsevents": {
1423
+ "version": "2.3.3",
1424
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1425
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1426
+ "dev": true,
1427
+ "hasInstallScript": true,
1428
+ "license": "MIT",
1429
+ "optional": true,
1430
+ "os": [
1431
+ "darwin"
1432
+ ],
1433
+ "engines": {
1434
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1435
+ }
1436
+ },
1437
+ "node_modules/gensync": {
1438
+ "version": "1.0.0-beta.2",
1439
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
1440
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
1441
+ "dev": true,
1442
+ "license": "MIT",
1443
+ "engines": {
1444
+ "node": ">=6.9.0"
1445
+ }
1446
+ },
1447
+ "node_modules/glob-parent": {
1448
+ "version": "6.0.2",
1449
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
1450
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
1451
+ "dev": true,
1452
+ "license": "ISC",
1453
+ "dependencies": {
1454
+ "is-glob": "^4.0.3"
1455
+ },
1456
+ "engines": {
1457
+ "node": ">=10.13.0"
1458
+ }
1459
+ },
1460
+ "node_modules/globals": {
1461
+ "version": "17.6.0",
1462
+ "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz",
1463
+ "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==",
1464
+ "dev": true,
1465
+ "license": "MIT",
1466
+ "engines": {
1467
+ "node": ">=18"
1468
+ },
1469
+ "funding": {
1470
+ "url": "https://github.com/sponsors/sindresorhus"
1471
+ }
1472
+ },
1473
+ "node_modules/hermes-estree": {
1474
+ "version": "0.25.1",
1475
+ "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
1476
+ "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
1477
+ "dev": true,
1478
+ "license": "MIT"
1479
+ },
1480
+ "node_modules/hermes-parser": {
1481
+ "version": "0.25.1",
1482
+ "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
1483
+ "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
1484
+ "dev": true,
1485
+ "license": "MIT",
1486
+ "dependencies": {
1487
+ "hermes-estree": "0.25.1"
1488
+ }
1489
+ },
1490
+ "node_modules/ignore": {
1491
+ "version": "5.3.2",
1492
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
1493
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
1494
+ "dev": true,
1495
+ "license": "MIT",
1496
+ "engines": {
1497
+ "node": ">= 4"
1498
+ }
1499
+ },
1500
+ "node_modules/imurmurhash": {
1501
+ "version": "0.1.4",
1502
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
1503
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
1504
+ "dev": true,
1505
+ "license": "MIT",
1506
+ "engines": {
1507
+ "node": ">=0.8.19"
1508
+ }
1509
+ },
1510
+ "node_modules/is-extglob": {
1511
+ "version": "2.1.1",
1512
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
1513
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
1514
+ "dev": true,
1515
+ "license": "MIT",
1516
+ "engines": {
1517
+ "node": ">=0.10.0"
1518
+ }
1519
+ },
1520
+ "node_modules/is-glob": {
1521
+ "version": "4.0.3",
1522
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
1523
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
1524
+ "dev": true,
1525
+ "license": "MIT",
1526
+ "dependencies": {
1527
+ "is-extglob": "^2.1.1"
1528
+ },
1529
+ "engines": {
1530
+ "node": ">=0.10.0"
1531
+ }
1532
+ },
1533
+ "node_modules/isexe": {
1534
+ "version": "2.0.0",
1535
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
1536
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
1537
+ "dev": true,
1538
+ "license": "ISC"
1539
+ },
1540
+ "node_modules/js-tokens": {
1541
+ "version": "4.0.0",
1542
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
1543
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
1544
+ "dev": true,
1545
+ "license": "MIT"
1546
+ },
1547
+ "node_modules/jsesc": {
1548
+ "version": "3.1.0",
1549
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
1550
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
1551
+ "dev": true,
1552
+ "license": "MIT",
1553
+ "bin": {
1554
+ "jsesc": "bin/jsesc"
1555
+ },
1556
+ "engines": {
1557
+ "node": ">=6"
1558
+ }
1559
+ },
1560
+ "node_modules/json-buffer": {
1561
+ "version": "3.0.1",
1562
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
1563
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
1564
+ "dev": true,
1565
+ "license": "MIT"
1566
+ },
1567
+ "node_modules/json-schema-traverse": {
1568
+ "version": "0.4.1",
1569
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
1570
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
1571
+ "dev": true,
1572
+ "license": "MIT"
1573
+ },
1574
+ "node_modules/json-stable-stringify-without-jsonify": {
1575
+ "version": "1.0.1",
1576
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
1577
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
1578
+ "dev": true,
1579
+ "license": "MIT"
1580
+ },
1581
+ "node_modules/json5": {
1582
+ "version": "2.2.3",
1583
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
1584
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
1585
+ "dev": true,
1586
+ "license": "MIT",
1587
+ "bin": {
1588
+ "json5": "lib/cli.js"
1589
+ },
1590
+ "engines": {
1591
+ "node": ">=6"
1592
+ }
1593
+ },
1594
+ "node_modules/keyv": {
1595
+ "version": "4.5.4",
1596
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
1597
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
1598
+ "dev": true,
1599
+ "license": "MIT",
1600
+ "dependencies": {
1601
+ "json-buffer": "3.0.1"
1602
+ }
1603
+ },
1604
+ "node_modules/levn": {
1605
+ "version": "0.4.1",
1606
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
1607
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
1608
+ "dev": true,
1609
+ "license": "MIT",
1610
+ "dependencies": {
1611
+ "prelude-ls": "^1.2.1",
1612
+ "type-check": "~0.4.0"
1613
+ },
1614
+ "engines": {
1615
+ "node": ">= 0.8.0"
1616
+ }
1617
+ },
1618
+ "node_modules/lightningcss": {
1619
+ "version": "1.32.0",
1620
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
1621
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
1622
+ "dev": true,
1623
+ "license": "MPL-2.0",
1624
+ "dependencies": {
1625
+ "detect-libc": "^2.0.3"
1626
+ },
1627
+ "engines": {
1628
+ "node": ">= 12.0.0"
1629
+ },
1630
+ "funding": {
1631
+ "type": "opencollective",
1632
+ "url": "https://opencollective.com/parcel"
1633
+ },
1634
+ "optionalDependencies": {
1635
+ "lightningcss-android-arm64": "1.32.0",
1636
+ "lightningcss-darwin-arm64": "1.32.0",
1637
+ "lightningcss-darwin-x64": "1.32.0",
1638
+ "lightningcss-freebsd-x64": "1.32.0",
1639
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
1640
+ "lightningcss-linux-arm64-gnu": "1.32.0",
1641
+ "lightningcss-linux-arm64-musl": "1.32.0",
1642
+ "lightningcss-linux-x64-gnu": "1.32.0",
1643
+ "lightningcss-linux-x64-musl": "1.32.0",
1644
+ "lightningcss-win32-arm64-msvc": "1.32.0",
1645
+ "lightningcss-win32-x64-msvc": "1.32.0"
1646
+ }
1647
+ },
1648
+ "node_modules/lightningcss-android-arm64": {
1649
+ "version": "1.32.0",
1650
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
1651
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
1652
+ "cpu": [
1653
+ "arm64"
1654
+ ],
1655
+ "dev": true,
1656
+ "license": "MPL-2.0",
1657
+ "optional": true,
1658
+ "os": [
1659
+ "android"
1660
+ ],
1661
+ "engines": {
1662
+ "node": ">= 12.0.0"
1663
+ },
1664
+ "funding": {
1665
+ "type": "opencollective",
1666
+ "url": "https://opencollective.com/parcel"
1667
+ }
1668
+ },
1669
+ "node_modules/lightningcss-darwin-arm64": {
1670
+ "version": "1.32.0",
1671
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
1672
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
1673
+ "cpu": [
1674
+ "arm64"
1675
+ ],
1676
+ "dev": true,
1677
+ "license": "MPL-2.0",
1678
+ "optional": true,
1679
+ "os": [
1680
+ "darwin"
1681
+ ],
1682
+ "engines": {
1683
+ "node": ">= 12.0.0"
1684
+ },
1685
+ "funding": {
1686
+ "type": "opencollective",
1687
+ "url": "https://opencollective.com/parcel"
1688
+ }
1689
+ },
1690
+ "node_modules/lightningcss-darwin-x64": {
1691
+ "version": "1.32.0",
1692
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
1693
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
1694
+ "cpu": [
1695
+ "x64"
1696
+ ],
1697
+ "dev": true,
1698
+ "license": "MPL-2.0",
1699
+ "optional": true,
1700
+ "os": [
1701
+ "darwin"
1702
+ ],
1703
+ "engines": {
1704
+ "node": ">= 12.0.0"
1705
+ },
1706
+ "funding": {
1707
+ "type": "opencollective",
1708
+ "url": "https://opencollective.com/parcel"
1709
+ }
1710
+ },
1711
+ "node_modules/lightningcss-freebsd-x64": {
1712
+ "version": "1.32.0",
1713
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
1714
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
1715
+ "cpu": [
1716
+ "x64"
1717
+ ],
1718
+ "dev": true,
1719
+ "license": "MPL-2.0",
1720
+ "optional": true,
1721
+ "os": [
1722
+ "freebsd"
1723
+ ],
1724
+ "engines": {
1725
+ "node": ">= 12.0.0"
1726
+ },
1727
+ "funding": {
1728
+ "type": "opencollective",
1729
+ "url": "https://opencollective.com/parcel"
1730
+ }
1731
+ },
1732
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
1733
+ "version": "1.32.0",
1734
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
1735
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
1736
+ "cpu": [
1737
+ "arm"
1738
+ ],
1739
+ "dev": true,
1740
+ "license": "MPL-2.0",
1741
+ "optional": true,
1742
+ "os": [
1743
+ "linux"
1744
+ ],
1745
+ "engines": {
1746
+ "node": ">= 12.0.0"
1747
+ },
1748
+ "funding": {
1749
+ "type": "opencollective",
1750
+ "url": "https://opencollective.com/parcel"
1751
+ }
1752
+ },
1753
+ "node_modules/lightningcss-linux-arm64-gnu": {
1754
+ "version": "1.32.0",
1755
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
1756
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
1757
+ "cpu": [
1758
+ "arm64"
1759
+ ],
1760
+ "dev": true,
1761
+ "license": "MPL-2.0",
1762
+ "optional": true,
1763
+ "os": [
1764
+ "linux"
1765
+ ],
1766
+ "engines": {
1767
+ "node": ">= 12.0.0"
1768
+ },
1769
+ "funding": {
1770
+ "type": "opencollective",
1771
+ "url": "https://opencollective.com/parcel"
1772
+ }
1773
+ },
1774
+ "node_modules/lightningcss-linux-arm64-musl": {
1775
+ "version": "1.32.0",
1776
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
1777
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
1778
+ "cpu": [
1779
+ "arm64"
1780
+ ],
1781
+ "dev": true,
1782
+ "license": "MPL-2.0",
1783
+ "optional": true,
1784
+ "os": [
1785
+ "linux"
1786
+ ],
1787
+ "engines": {
1788
+ "node": ">= 12.0.0"
1789
+ },
1790
+ "funding": {
1791
+ "type": "opencollective",
1792
+ "url": "https://opencollective.com/parcel"
1793
+ }
1794
+ },
1795
+ "node_modules/lightningcss-linux-x64-gnu": {
1796
+ "version": "1.32.0",
1797
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
1798
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
1799
+ "cpu": [
1800
+ "x64"
1801
+ ],
1802
+ "dev": true,
1803
+ "license": "MPL-2.0",
1804
+ "optional": true,
1805
+ "os": [
1806
+ "linux"
1807
+ ],
1808
+ "engines": {
1809
+ "node": ">= 12.0.0"
1810
+ },
1811
+ "funding": {
1812
+ "type": "opencollective",
1813
+ "url": "https://opencollective.com/parcel"
1814
+ }
1815
+ },
1816
+ "node_modules/lightningcss-linux-x64-musl": {
1817
+ "version": "1.32.0",
1818
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
1819
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
1820
+ "cpu": [
1821
+ "x64"
1822
+ ],
1823
+ "dev": true,
1824
+ "license": "MPL-2.0",
1825
+ "optional": true,
1826
+ "os": [
1827
+ "linux"
1828
+ ],
1829
+ "engines": {
1830
+ "node": ">= 12.0.0"
1831
+ },
1832
+ "funding": {
1833
+ "type": "opencollective",
1834
+ "url": "https://opencollective.com/parcel"
1835
+ }
1836
+ },
1837
+ "node_modules/lightningcss-win32-arm64-msvc": {
1838
+ "version": "1.32.0",
1839
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
1840
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
1841
+ "cpu": [
1842
+ "arm64"
1843
+ ],
1844
+ "dev": true,
1845
+ "license": "MPL-2.0",
1846
+ "optional": true,
1847
+ "os": [
1848
+ "win32"
1849
+ ],
1850
+ "engines": {
1851
+ "node": ">= 12.0.0"
1852
+ },
1853
+ "funding": {
1854
+ "type": "opencollective",
1855
+ "url": "https://opencollective.com/parcel"
1856
+ }
1857
+ },
1858
+ "node_modules/lightningcss-win32-x64-msvc": {
1859
+ "version": "1.32.0",
1860
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
1861
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
1862
+ "cpu": [
1863
+ "x64"
1864
+ ],
1865
+ "dev": true,
1866
+ "license": "MPL-2.0",
1867
+ "optional": true,
1868
+ "os": [
1869
+ "win32"
1870
+ ],
1871
+ "engines": {
1872
+ "node": ">= 12.0.0"
1873
+ },
1874
+ "funding": {
1875
+ "type": "opencollective",
1876
+ "url": "https://opencollective.com/parcel"
1877
+ }
1878
+ },
1879
+ "node_modules/locate-path": {
1880
+ "version": "6.0.0",
1881
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
1882
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
1883
+ "dev": true,
1884
+ "license": "MIT",
1885
+ "dependencies": {
1886
+ "p-locate": "^5.0.0"
1887
+ },
1888
+ "engines": {
1889
+ "node": ">=10"
1890
+ },
1891
+ "funding": {
1892
+ "url": "https://github.com/sponsors/sindresorhus"
1893
+ }
1894
+ },
1895
+ "node_modules/lru-cache": {
1896
+ "version": "5.1.1",
1897
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
1898
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
1899
+ "dev": true,
1900
+ "license": "ISC",
1901
+ "dependencies": {
1902
+ "yallist": "^3.0.2"
1903
+ }
1904
+ },
1905
+ "node_modules/minimatch": {
1906
+ "version": "10.2.5",
1907
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
1908
+ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
1909
+ "dev": true,
1910
+ "license": "BlueOak-1.0.0",
1911
+ "dependencies": {
1912
+ "brace-expansion": "^5.0.5"
1913
+ },
1914
+ "engines": {
1915
+ "node": "18 || 20 || >=22"
1916
+ },
1917
+ "funding": {
1918
+ "url": "https://github.com/sponsors/isaacs"
1919
+ }
1920
+ },
1921
+ "node_modules/ms": {
1922
+ "version": "2.1.3",
1923
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1924
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1925
+ "dev": true,
1926
+ "license": "MIT"
1927
+ },
1928
+ "node_modules/nanoid": {
1929
+ "version": "3.3.12",
1930
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
1931
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
1932
+ "dev": true,
1933
+ "funding": [
1934
+ {
1935
+ "type": "github",
1936
+ "url": "https://github.com/sponsors/ai"
1937
+ }
1938
+ ],
1939
+ "license": "MIT",
1940
+ "bin": {
1941
+ "nanoid": "bin/nanoid.cjs"
1942
+ },
1943
+ "engines": {
1944
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1945
+ }
1946
+ },
1947
+ "node_modules/natural-compare": {
1948
+ "version": "1.4.0",
1949
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
1950
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
1951
+ "dev": true,
1952
+ "license": "MIT"
1953
+ },
1954
+ "node_modules/node-releases": {
1955
+ "version": "2.0.47",
1956
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz",
1957
+ "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==",
1958
+ "dev": true,
1959
+ "license": "MIT",
1960
+ "engines": {
1961
+ "node": ">=18"
1962
+ }
1963
+ },
1964
+ "node_modules/optionator": {
1965
+ "version": "0.9.4",
1966
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
1967
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
1968
+ "dev": true,
1969
+ "license": "MIT",
1970
+ "dependencies": {
1971
+ "deep-is": "^0.1.3",
1972
+ "fast-levenshtein": "^2.0.6",
1973
+ "levn": "^0.4.1",
1974
+ "prelude-ls": "^1.2.1",
1975
+ "type-check": "^0.4.0",
1976
+ "word-wrap": "^1.2.5"
1977
+ },
1978
+ "engines": {
1979
+ "node": ">= 0.8.0"
1980
+ }
1981
+ },
1982
+ "node_modules/p-limit": {
1983
+ "version": "3.1.0",
1984
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
1985
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
1986
+ "dev": true,
1987
+ "license": "MIT",
1988
+ "dependencies": {
1989
+ "yocto-queue": "^0.1.0"
1990
+ },
1991
+ "engines": {
1992
+ "node": ">=10"
1993
+ },
1994
+ "funding": {
1995
+ "url": "https://github.com/sponsors/sindresorhus"
1996
+ }
1997
+ },
1998
+ "node_modules/p-locate": {
1999
+ "version": "5.0.0",
2000
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
2001
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
2002
+ "dev": true,
2003
+ "license": "MIT",
2004
+ "dependencies": {
2005
+ "p-limit": "^3.0.2"
2006
+ },
2007
+ "engines": {
2008
+ "node": ">=10"
2009
+ },
2010
+ "funding": {
2011
+ "url": "https://github.com/sponsors/sindresorhus"
2012
+ }
2013
+ },
2014
+ "node_modules/path-exists": {
2015
+ "version": "4.0.0",
2016
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
2017
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
2018
+ "dev": true,
2019
+ "license": "MIT",
2020
+ "engines": {
2021
+ "node": ">=8"
2022
+ }
2023
+ },
2024
+ "node_modules/path-key": {
2025
+ "version": "3.1.1",
2026
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
2027
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
2028
+ "dev": true,
2029
+ "license": "MIT",
2030
+ "engines": {
2031
+ "node": ">=8"
2032
+ }
2033
+ },
2034
+ "node_modules/picocolors": {
2035
+ "version": "1.1.1",
2036
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
2037
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
2038
+ "dev": true,
2039
+ "license": "ISC"
2040
+ },
2041
+ "node_modules/picomatch": {
2042
+ "version": "4.0.4",
2043
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
2044
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
2045
+ "dev": true,
2046
+ "license": "MIT",
2047
+ "peer": true,
2048
+ "engines": {
2049
+ "node": ">=12"
2050
+ },
2051
+ "funding": {
2052
+ "url": "https://github.com/sponsors/jonschlinkert"
2053
+ }
2054
+ },
2055
+ "node_modules/postcss": {
2056
+ "version": "8.5.15",
2057
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
2058
+ "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
2059
+ "dev": true,
2060
+ "funding": [
2061
+ {
2062
+ "type": "opencollective",
2063
+ "url": "https://opencollective.com/postcss/"
2064
+ },
2065
+ {
2066
+ "type": "tidelift",
2067
+ "url": "https://tidelift.com/funding/github/npm/postcss"
2068
+ },
2069
+ {
2070
+ "type": "github",
2071
+ "url": "https://github.com/sponsors/ai"
2072
+ }
2073
+ ],
2074
+ "license": "MIT",
2075
+ "dependencies": {
2076
+ "nanoid": "^3.3.12",
2077
+ "picocolors": "^1.1.1",
2078
+ "source-map-js": "^1.2.1"
2079
+ },
2080
+ "engines": {
2081
+ "node": "^10 || ^12 || >=14"
2082
+ }
2083
+ },
2084
+ "node_modules/prelude-ls": {
2085
+ "version": "1.2.1",
2086
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
2087
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
2088
+ "dev": true,
2089
+ "license": "MIT",
2090
+ "engines": {
2091
+ "node": ">= 0.8.0"
2092
+ }
2093
+ },
2094
+ "node_modules/punycode": {
2095
+ "version": "2.3.1",
2096
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
2097
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
2098
+ "dev": true,
2099
+ "license": "MIT",
2100
+ "engines": {
2101
+ "node": ">=6"
2102
+ }
2103
+ },
2104
+ "node_modules/react": {
2105
+ "version": "19.2.7",
2106
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
2107
+ "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
2108
+ "license": "MIT",
2109
+ "peer": true,
2110
+ "engines": {
2111
+ "node": ">=0.10.0"
2112
+ }
2113
+ },
2114
+ "node_modules/react-dom": {
2115
+ "version": "19.2.7",
2116
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
2117
+ "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
2118
+ "license": "MIT",
2119
+ "dependencies": {
2120
+ "scheduler": "^0.27.0"
2121
+ },
2122
+ "peerDependencies": {
2123
+ "react": "^19.2.7"
2124
+ }
2125
+ },
2126
+ "node_modules/rolldown": {
2127
+ "version": "1.0.3",
2128
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
2129
+ "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
2130
+ "dev": true,
2131
+ "license": "MIT",
2132
+ "dependencies": {
2133
+ "@oxc-project/types": "=0.133.0",
2134
+ "@rolldown/pluginutils": "^1.0.0"
2135
+ },
2136
+ "bin": {
2137
+ "rolldown": "bin/cli.mjs"
2138
+ },
2139
+ "engines": {
2140
+ "node": "^20.19.0 || >=22.12.0"
2141
+ },
2142
+ "optionalDependencies": {
2143
+ "@rolldown/binding-android-arm64": "1.0.3",
2144
+ "@rolldown/binding-darwin-arm64": "1.0.3",
2145
+ "@rolldown/binding-darwin-x64": "1.0.3",
2146
+ "@rolldown/binding-freebsd-x64": "1.0.3",
2147
+ "@rolldown/binding-linux-arm-gnueabihf": "1.0.3",
2148
+ "@rolldown/binding-linux-arm64-gnu": "1.0.3",
2149
+ "@rolldown/binding-linux-arm64-musl": "1.0.3",
2150
+ "@rolldown/binding-linux-ppc64-gnu": "1.0.3",
2151
+ "@rolldown/binding-linux-s390x-gnu": "1.0.3",
2152
+ "@rolldown/binding-linux-x64-gnu": "1.0.3",
2153
+ "@rolldown/binding-linux-x64-musl": "1.0.3",
2154
+ "@rolldown/binding-openharmony-arm64": "1.0.3",
2155
+ "@rolldown/binding-wasm32-wasi": "1.0.3",
2156
+ "@rolldown/binding-win32-arm64-msvc": "1.0.3",
2157
+ "@rolldown/binding-win32-x64-msvc": "1.0.3"
2158
+ }
2159
+ },
2160
+ "node_modules/scheduler": {
2161
+ "version": "0.27.0",
2162
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
2163
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
2164
+ "license": "MIT"
2165
+ },
2166
+ "node_modules/semver": {
2167
+ "version": "6.3.1",
2168
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
2169
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
2170
+ "dev": true,
2171
+ "license": "ISC",
2172
+ "bin": {
2173
+ "semver": "bin/semver.js"
2174
+ }
2175
+ },
2176
+ "node_modules/shebang-command": {
2177
+ "version": "2.0.0",
2178
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
2179
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
2180
+ "dev": true,
2181
+ "license": "MIT",
2182
+ "dependencies": {
2183
+ "shebang-regex": "^3.0.0"
2184
+ },
2185
+ "engines": {
2186
+ "node": ">=8"
2187
+ }
2188
+ },
2189
+ "node_modules/shebang-regex": {
2190
+ "version": "3.0.0",
2191
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
2192
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
2193
+ "dev": true,
2194
+ "license": "MIT",
2195
+ "engines": {
2196
+ "node": ">=8"
2197
+ }
2198
+ },
2199
+ "node_modules/source-map-js": {
2200
+ "version": "1.2.1",
2201
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
2202
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
2203
+ "dev": true,
2204
+ "license": "BSD-3-Clause",
2205
+ "engines": {
2206
+ "node": ">=0.10.0"
2207
+ }
2208
+ },
2209
+ "node_modules/tinyglobby": {
2210
+ "version": "0.2.17",
2211
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
2212
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
2213
+ "dev": true,
2214
+ "license": "MIT",
2215
+ "dependencies": {
2216
+ "fdir": "^6.5.0",
2217
+ "picomatch": "^4.0.4"
2218
+ },
2219
+ "engines": {
2220
+ "node": ">=12.0.0"
2221
+ },
2222
+ "funding": {
2223
+ "url": "https://github.com/sponsors/SuperchupuDev"
2224
+ }
2225
+ },
2226
+ "node_modules/tslib": {
2227
+ "version": "2.8.1",
2228
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
2229
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
2230
+ "dev": true,
2231
+ "license": "0BSD",
2232
+ "optional": true
2233
+ },
2234
+ "node_modules/type-check": {
2235
+ "version": "0.4.0",
2236
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
2237
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
2238
+ "dev": true,
2239
+ "license": "MIT",
2240
+ "dependencies": {
2241
+ "prelude-ls": "^1.2.1"
2242
+ },
2243
+ "engines": {
2244
+ "node": ">= 0.8.0"
2245
+ }
2246
+ },
2247
+ "node_modules/update-browserslist-db": {
2248
+ "version": "1.2.3",
2249
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
2250
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
2251
+ "dev": true,
2252
+ "funding": [
2253
+ {
2254
+ "type": "opencollective",
2255
+ "url": "https://opencollective.com/browserslist"
2256
+ },
2257
+ {
2258
+ "type": "tidelift",
2259
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
2260
+ },
2261
+ {
2262
+ "type": "github",
2263
+ "url": "https://github.com/sponsors/ai"
2264
+ }
2265
+ ],
2266
+ "license": "MIT",
2267
+ "dependencies": {
2268
+ "escalade": "^3.2.0",
2269
+ "picocolors": "^1.1.1"
2270
+ },
2271
+ "bin": {
2272
+ "update-browserslist-db": "cli.js"
2273
+ },
2274
+ "peerDependencies": {
2275
+ "browserslist": ">= 4.21.0"
2276
+ }
2277
+ },
2278
+ "node_modules/uri-js": {
2279
+ "version": "4.4.1",
2280
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
2281
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
2282
+ "dev": true,
2283
+ "license": "BSD-2-Clause",
2284
+ "dependencies": {
2285
+ "punycode": "^2.1.0"
2286
+ }
2287
+ },
2288
+ "node_modules/vite": {
2289
+ "version": "8.0.16",
2290
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
2291
+ "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
2292
+ "dev": true,
2293
+ "license": "MIT",
2294
+ "peer": true,
2295
+ "dependencies": {
2296
+ "lightningcss": "^1.32.0",
2297
+ "picomatch": "^4.0.4",
2298
+ "postcss": "^8.5.15",
2299
+ "rolldown": "1.0.3",
2300
+ "tinyglobby": "^0.2.17"
2301
+ },
2302
+ "bin": {
2303
+ "vite": "bin/vite.js"
2304
+ },
2305
+ "engines": {
2306
+ "node": "^20.19.0 || >=22.12.0"
2307
+ },
2308
+ "funding": {
2309
+ "url": "https://github.com/vitejs/vite?sponsor=1"
2310
+ },
2311
+ "optionalDependencies": {
2312
+ "fsevents": "~2.3.3"
2313
+ },
2314
+ "peerDependencies": {
2315
+ "@types/node": "^20.19.0 || >=22.12.0",
2316
+ "@vitejs/devtools": "^0.1.18",
2317
+ "esbuild": "^0.27.0 || ^0.28.0",
2318
+ "jiti": ">=1.21.0",
2319
+ "less": "^4.0.0",
2320
+ "sass": "^1.70.0",
2321
+ "sass-embedded": "^1.70.0",
2322
+ "stylus": ">=0.54.8",
2323
+ "sugarss": "^5.0.0",
2324
+ "terser": "^5.16.0",
2325
+ "tsx": "^4.8.1",
2326
+ "yaml": "^2.4.2"
2327
+ },
2328
+ "peerDependenciesMeta": {
2329
+ "@types/node": {
2330
+ "optional": true
2331
+ },
2332
+ "@vitejs/devtools": {
2333
+ "optional": true
2334
+ },
2335
+ "esbuild": {
2336
+ "optional": true
2337
+ },
2338
+ "jiti": {
2339
+ "optional": true
2340
+ },
2341
+ "less": {
2342
+ "optional": true
2343
+ },
2344
+ "sass": {
2345
+ "optional": true
2346
+ },
2347
+ "sass-embedded": {
2348
+ "optional": true
2349
+ },
2350
+ "stylus": {
2351
+ "optional": true
2352
+ },
2353
+ "sugarss": {
2354
+ "optional": true
2355
+ },
2356
+ "terser": {
2357
+ "optional": true
2358
+ },
2359
+ "tsx": {
2360
+ "optional": true
2361
+ },
2362
+ "yaml": {
2363
+ "optional": true
2364
+ }
2365
+ }
2366
+ },
2367
+ "node_modules/which": {
2368
+ "version": "2.0.2",
2369
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
2370
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
2371
+ "dev": true,
2372
+ "license": "ISC",
2373
+ "dependencies": {
2374
+ "isexe": "^2.0.0"
2375
+ },
2376
+ "bin": {
2377
+ "node-which": "bin/node-which"
2378
+ },
2379
+ "engines": {
2380
+ "node": ">= 8"
2381
+ }
2382
+ },
2383
+ "node_modules/word-wrap": {
2384
+ "version": "1.2.5",
2385
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
2386
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
2387
+ "dev": true,
2388
+ "license": "MIT",
2389
+ "engines": {
2390
+ "node": ">=0.10.0"
2391
+ }
2392
+ },
2393
+ "node_modules/yallist": {
2394
+ "version": "3.1.1",
2395
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
2396
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
2397
+ "dev": true,
2398
+ "license": "ISC"
2399
+ },
2400
+ "node_modules/yocto-queue": {
2401
+ "version": "0.1.0",
2402
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
2403
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
2404
+ "dev": true,
2405
+ "license": "MIT",
2406
+ "engines": {
2407
+ "node": ">=10"
2408
+ },
2409
+ "funding": {
2410
+ "url": "https://github.com/sponsors/sindresorhus"
2411
+ }
2412
+ },
2413
+ "node_modules/zod": {
2414
+ "version": "4.4.3",
2415
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
2416
+ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
2417
+ "dev": true,
2418
+ "license": "MIT",
2419
+ "peer": true,
2420
+ "funding": {
2421
+ "url": "https://github.com/sponsors/colinhacks"
2422
+ }
2423
+ },
2424
+ "node_modules/zod-validation-error": {
2425
+ "version": "4.0.2",
2426
+ "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
2427
+ "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
2428
+ "dev": true,
2429
+ "license": "MIT",
2430
+ "engines": {
2431
+ "node": ">=18.0.0"
2432
+ },
2433
+ "peerDependencies": {
2434
+ "zod": "^3.25.0 || ^4.0.0"
2435
+ }
2436
+ }
2437
+ }
2438
+ }
frontend/package.json ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "frontend",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "lint": "eslint .",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "react": "^19.2.6",
14
+ "react-dom": "^19.2.6"
15
+ },
16
+ "devDependencies": {
17
+ "@eslint/js": "^10.0.1",
18
+ "@types/react": "^19.2.14",
19
+ "@types/react-dom": "^19.2.3",
20
+ "@vitejs/plugin-react": "^6.0.1",
21
+ "eslint": "^10.3.0",
22
+ "eslint-plugin-react-hooks": "^7.1.1",
23
+ "eslint-plugin-react-refresh": "^0.5.2",
24
+ "globals": "^17.6.0",
25
+ "vite": "^8.0.12"
26
+ }
27
+ }
frontend/public/favicon.svg ADDED
frontend/public/icons.svg ADDED
frontend/src/App.css ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .counter {
2
+ font-size: 16px;
3
+ padding: 5px 10px;
4
+ border-radius: 5px;
5
+ color: var(--accent);
6
+ background: var(--accent-bg);
7
+ border: 2px solid transparent;
8
+ transition: border-color 0.3s;
9
+ margin-bottom: 24px;
10
+
11
+ &:hover {
12
+ border-color: var(--accent-border);
13
+ }
14
+ &:focus-visible {
15
+ outline: 2px solid var(--accent);
16
+ outline-offset: 2px;
17
+ }
18
+ }
19
+
20
+ .hero {
21
+ position: relative;
22
+
23
+ .base,
24
+ .framework,
25
+ .vite {
26
+ inset-inline: 0;
27
+ margin: 0 auto;
28
+ }
29
+
30
+ .base {
31
+ width: 170px;
32
+ position: relative;
33
+ z-index: 0;
34
+ }
35
+
36
+ .framework,
37
+ .vite {
38
+ position: absolute;
39
+ }
40
+
41
+ .framework {
42
+ z-index: 1;
43
+ top: 34px;
44
+ height: 28px;
45
+ transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
46
+ scale(1.4);
47
+ }
48
+
49
+ .vite {
50
+ z-index: 0;
51
+ top: 107px;
52
+ height: 26px;
53
+ width: auto;
54
+ transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
55
+ scale(0.8);
56
+ }
57
+ }
58
+
59
+ #center {
60
+ display: flex;
61
+ flex-direction: column;
62
+ gap: 25px;
63
+ place-content: center;
64
+ place-items: center;
65
+ flex-grow: 1;
66
+
67
+ @media (max-width: 1024px) {
68
+ padding: 32px 20px 24px;
69
+ gap: 18px;
70
+ }
71
+ }
72
+
73
+ #next-steps {
74
+ display: flex;
75
+ border-top: 1px solid var(--border);
76
+ text-align: left;
77
+
78
+ & > div {
79
+ flex: 1 1 0;
80
+ padding: 32px;
81
+ @media (max-width: 1024px) {
82
+ padding: 24px 20px;
83
+ }
84
+ }
85
+
86
+ .icon {
87
+ margin-bottom: 16px;
88
+ width: 22px;
89
+ height: 22px;
90
+ }
91
+
92
+ @media (max-width: 1024px) {
93
+ flex-direction: column;
94
+ text-align: center;
95
+ }
96
+ }
97
+
98
+ #docs {
99
+ border-right: 1px solid var(--border);
100
+
101
+ @media (max-width: 1024px) {
102
+ border-right: none;
103
+ border-bottom: 1px solid var(--border);
104
+ }
105
+ }
106
+
107
+ #next-steps ul {
108
+ list-style: none;
109
+ padding: 0;
110
+ display: flex;
111
+ gap: 8px;
112
+ margin: 32px 0 0;
113
+
114
+ .logo {
115
+ height: 18px;
116
+ }
117
+
118
+ a {
119
+ color: var(--text-h);
120
+ font-size: 16px;
121
+ border-radius: 6px;
122
+ background: var(--social-bg);
123
+ display: flex;
124
+ padding: 6px 12px;
125
+ align-items: center;
126
+ gap: 8px;
127
+ text-decoration: none;
128
+ transition: box-shadow 0.3s;
129
+
130
+ &:hover {
131
+ box-shadow: var(--shadow);
132
+ }
133
+ .button-icon {
134
+ height: 18px;
135
+ width: 18px;
136
+ }
137
+ }
138
+
139
+ @media (max-width: 1024px) {
140
+ margin-top: 20px;
141
+ flex-wrap: wrap;
142
+ justify-content: center;
143
+
144
+ li {
145
+ flex: 1 1 calc(50% - 8px);
146
+ }
147
+
148
+ a {
149
+ width: 100%;
150
+ justify-content: center;
151
+ box-sizing: border-box;
152
+ }
153
+ }
154
+ }
155
+
156
+ #spacer {
157
+ height: 88px;
158
+ border-top: 1px solid var(--border);
159
+ @media (max-width: 1024px) {
160
+ height: 48px;
161
+ }
162
+ }
163
+
164
+ .ticks {
165
+ position: relative;
166
+ width: 100%;
167
+
168
+ &::before,
169
+ &::after {
170
+ content: '';
171
+ position: absolute;
172
+ top: -4.5px;
173
+ border: 5px solid transparent;
174
+ }
175
+
176
+ &::before {
177
+ left: 0;
178
+ border-left-color: var(--border);
179
+ }
180
+ &::after {
181
+ right: 0;
182
+ border-right-color: var(--border);
183
+ }
184
+ }
frontend/src/App.jsx ADDED
@@ -0,0 +1,409 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from "react";
2
+ import Sidebar from "./components/Sidebar";
3
+ import UploadView from "./components/UploadView";
4
+ import DataPreview from "./components/DataPreview";
5
+ import QueryView from "./components/QueryView";
6
+ import SchemaPreviewPanel from "./components/SchemaPreviewPanel";
7
+ import SchemaQueryView from "./components/SchemaQueryView";
8
+ import SchemaUploadModal from "./components/SchemaUploadModal";
9
+ import { getDatasets, getSchema, getPreview, getQueryHistory, getSchemaDatasets, getSchemaInfo, getSchemaPreview } from "./api";
10
+
11
+ export default function App() {
12
+ const [datasets, setDatasets] = useState([]);
13
+ const [schemaDatasets, setSchemaDatasets] = useState([]);
14
+ const [selectedDataset, setSelectedDataset] = useState(null);
15
+ const [view, setView] = useState("upload"); // upload | preview | query
16
+ const [globalLoading, setGlobalLoading] = useState(false);
17
+ const [datasetsLoading, setDatasetsLoading] = useState(true);
18
+
19
+ // Added polish features
20
+ const [history, setHistory] = useState([]);
21
+ const [queriesRun, setQueriesRun] = useState(0);
22
+ const [activeQuestion, setActiveQuestion] = useState(null);
23
+ const [toasts, setToasts] = useState([]);
24
+ const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
25
+ const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
26
+
27
+ // 1. Initial Load: Fetch all datasets
28
+ useEffect(() => {
29
+ fetchDatasetsList();
30
+ }, []);
31
+
32
+ // Resize listener for mobile responsiveness
33
+ useEffect(() => {
34
+ const handleResize = () => {
35
+ setIsMobile(window.innerWidth < 768);
36
+ };
37
+ window.addEventListener("resize", handleResize);
38
+ return () => window.removeEventListener("resize", handleResize);
39
+ }, []);
40
+
41
+ // Toast Notification manager
42
+ const addToast = (message, type = "info") => {
43
+ const id = Date.now();
44
+ setToasts((prev) => [...prev, { id, message, type }]);
45
+ setTimeout(() => {
46
+ setToasts((prev) => prev.filter((t) => t.id !== id));
47
+ }, 3000);
48
+ };
49
+
50
+ const fetchDatasetsList = async () => {
51
+ try {
52
+ setDatasetsLoading(true);
53
+ const [csvRes, schemaRes] = await Promise.all([
54
+ getDatasets(),
55
+ getSchemaDatasets().catch(() => ({ datasets: [], total: 0 }))
56
+ ]);
57
+ setDatasets(csvRes.datasets || []);
58
+ setSchemaDatasets(schemaRes.datasets || []);
59
+ setDatasetsLoading(false);
60
+ } catch (err) {
61
+ console.error("Failed to load datasets list:", err);
62
+ setDatasetsLoading(false);
63
+ }
64
+ };
65
+
66
+ const fetchQueryHistory = async (dbName) => {
67
+ try {
68
+ const res = await getQueryHistory(dbName);
69
+ setHistory(res.history || []);
70
+ } catch (err) {
71
+ console.error("Failed to fetch query history:", err);
72
+ }
73
+ };
74
+
75
+ // 2. Select Dataset: Fetch details and preview in parallel
76
+ const handleSelectDataset = async (datasetInfo) => {
77
+ setGlobalLoading(true);
78
+ try {
79
+ console.log("[App] Selecting dataset:", datasetInfo.db_name, "mode:", datasetInfo.mode);
80
+
81
+ if (datasetInfo.mode === "schema") {
82
+ const [schemaInfoRes, historyRes] = await Promise.all([
83
+ getSchemaInfo(datasetInfo.db_name),
84
+ getQueryHistory(datasetInfo.db_name).catch(() => ({ history: [] }))
85
+ ]);
86
+
87
+ const mergedDataset = {
88
+ db_name: datasetInfo.db_name,
89
+ display_name: datasetInfo.display_name,
90
+ description: datasetInfo.description,
91
+ is_sample: datasetInfo.is_sample,
92
+ mode: "schema",
93
+ total_tables: schemaInfoRes.total_tables,
94
+ total_rows: schemaInfoRes.total_rows,
95
+ relationships: schemaInfoRes.relationships,
96
+ tables: schemaInfoRes.tables
97
+ };
98
+
99
+ setSelectedDataset(mergedDataset);
100
+ setHistory(historyRes.history || []);
101
+ setView("preview");
102
+ setGlobalLoading(false);
103
+ return;
104
+ }
105
+
106
+ // Existing CSV mode:
107
+ const [schemaRes, previewRes, historyRes] = await Promise.all([
108
+ getSchema(datasetInfo.db_name),
109
+ getPreview(datasetInfo.db_name),
110
+ getQueryHistory(datasetInfo.db_name).catch(() => ({ history: [] }))
111
+ ]);
112
+
113
+ const mergedDataset = {
114
+ db_name: datasetInfo.db_name,
115
+ display_name: datasetInfo.display_name,
116
+ description: datasetInfo.description,
117
+ is_sample: datasetInfo.is_sample,
118
+ mode: "csv",
119
+ row_count: schemaRes.row_count,
120
+ columns: schemaRes.columns, // columns details list {name, type, sample_values}
121
+ preview: previewRes // preview data columns and rows
122
+ };
123
+
124
+ setSelectedDataset(mergedDataset);
125
+ setHistory(historyRes.history || []);
126
+ setView("preview");
127
+ setGlobalLoading(false);
128
+ } catch (err) {
129
+ console.error("[App] Failed to load dataset details:", err);
130
+ addToast(`Failed to load dataset details: ${err.message}`, "error");
131
+ setGlobalLoading(false);
132
+ }
133
+ };
134
+
135
+ // 3. Upload Success Callback: Refresh datasets and select new database
136
+ const handleUploadSuccess = async (uploadResult) => {
137
+ console.log("[App] CSV Upload success callback triggered:", uploadResult);
138
+ addToast("CSV uploaded successfully", "success");
139
+ await fetchDatasetsList();
140
+ handleSelectDataset({
141
+ db_name: uploadResult.db_name,
142
+ display_name: uploadResult.display_name,
143
+ description: uploadResult.message,
144
+ is_sample: false,
145
+ mode: "csv"
146
+ });
147
+ };
148
+
149
+ const handleSchemaUploadSuccess = async (uploadResult) => {
150
+ console.log("[App] Schema Upload success callback:", uploadResult);
151
+ await fetchDatasetsList();
152
+
153
+ const displayFriendlyName = uploadResult.db_name.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase());
154
+ handleSelectDataset({
155
+ db_name: uploadResult.db_name,
156
+ display_name: displayFriendlyName,
157
+ description: uploadResult.message,
158
+ is_sample: false,
159
+ mode: "schema"
160
+ });
161
+ };
162
+
163
+ // 4. Delete Success Callback: Refresh list and redirect view if active
164
+ const handleDeleteDataset = async (deletedDbName, mode = "csv") => {
165
+ console.log(`[App] Dataset deleted callback triggered: ${deletedDbName} (mode=${mode})`);
166
+ addToast("Dataset deleted", "info");
167
+ if (selectedDataset && selectedDataset.db_name === deletedDbName) {
168
+ setSelectedDataset(null);
169
+ setView("upload");
170
+ setHistory([]);
171
+ }
172
+ fetchDatasetsList();
173
+ };
174
+
175
+ const handleStartQuerying = () => {
176
+ setView("query");
177
+ };
178
+
179
+ const handleQuerySuccess = () => {
180
+ setQueriesRun((prev) => prev + 1);
181
+ if (selectedDataset) {
182
+ fetchQueryHistory(selectedDataset.db_name);
183
+ }
184
+ };
185
+
186
+ const handleRunQuestion = (questionText) => {
187
+ setView("query");
188
+ setActiveQuestion({ text: questionText, timestamp: Date.now() });
189
+ };
190
+
191
+ return (
192
+ <div style={{ display: "flex", flexDirection: "column", height: "100vh", width: "100vw", overflow: "hidden" }}>
193
+ {/* Toast Overlay Container */}
194
+ <div className="toast-container">
195
+ {toasts.map((t) => (
196
+ <div key={t.id} className={`toast toast-${t.type}`}>
197
+ <span style={{ marginRight: "12px", wordBreak: "break-word" }}>{t.message}</span>
198
+ <button
199
+ className="toast-close-btn"
200
+ onClick={() => setToasts((prev) => prev.filter((item) => item.id !== t.id))}
201
+ >
202
+ &times;
203
+ </button>
204
+ </div>
205
+ ))}
206
+ </div>
207
+
208
+ {/* Relational Database Upload Modal */}
209
+ {isUploadModalOpen && (
210
+ <SchemaUploadModal
211
+ onClose={() => setIsUploadModalOpen(false)}
212
+ onUploadSuccess={handleSchemaUploadSuccess}
213
+ addToast={addToast}
214
+ />
215
+ )}
216
+
217
+ {/* Top Full-width Stats Bar */}
218
+ <div className="stats-bar">
219
+ <div>
220
+ 📊 {datasets.length + schemaDatasets.length} datasets • 🔍 {queriesRun} queries run
221
+ </div>
222
+ {isMobile && (
223
+ <select
224
+ value={selectedDataset ? `${selectedDataset.mode}:${selectedDataset.db_name}` : ""}
225
+ onChange={(e) => {
226
+ const val = e.target.value;
227
+ if (val === "upload") {
228
+ setSelectedDataset(null);
229
+ setView("upload");
230
+ } else if (val === "upload_schema") {
231
+ setIsUploadModalOpen(true);
232
+ } else if (val) {
233
+ const [mode, dbName] = val.split(":");
234
+ if (mode === "csv") {
235
+ const ds = datasets.find((d) => d.db_name === dbName);
236
+ if (ds) handleSelectDataset({ ...ds, mode: "csv" });
237
+ } else if (mode === "schema") {
238
+ const ds = schemaDatasets.find((d) => d.db_name === dbName);
239
+ if (ds) handleSelectDataset({ ...ds, mode: "schema" });
240
+ }
241
+ }
242
+ }}
243
+ className="mobile-dataset-select"
244
+ style={{
245
+ backgroundColor: "var(--surface-color)",
246
+ border: "1px solid var(--border-color)",
247
+ color: "var(--text-primary)",
248
+ borderRadius: "6px",
249
+ padding: "4px 8px",
250
+ fontSize: "0.75rem",
251
+ outline: "none"
252
+ }}
253
+ >
254
+ <option value="" disabled>Select dataset...</option>
255
+ <optgroup label="Sample CSV Datasets">
256
+ {datasets.filter(d => d.is_sample).map(d => (
257
+ <option key={d.db_name} value={`csv:${d.db_name}`}>{d.display_name}</option>
258
+ ))}
259
+ </optgroup>
260
+ {datasets.some(d => !d.is_sample) && (
261
+ <optgroup label="Your CSV Datasets">
262
+ {datasets.filter(d => !d.is_sample).map(d => (
263
+ <option key={d.db_name} value={`csv:${d.db_name}`}>{d.display_name}</option>
264
+ ))}
265
+ </optgroup>
266
+ )}
267
+ <optgroup label="Sample Relational Databases">
268
+ {schemaDatasets.filter(d => d.is_sample).map(d => (
269
+ <option key={d.db_name} value={`schema:${d.db_name}`}>{d.display_name}</option>
270
+ ))}
271
+ </optgroup>
272
+ {schemaDatasets.some(d => !d.is_sample) && (
273
+ <optgroup label="Your Relational Databases">
274
+ {schemaDatasets.filter(d => !d.is_sample).map(d => (
275
+ <option key={d.db_name} value={`schema:${d.db_name}`}>{d.display_name}</option>
276
+ ))}
277
+ </optgroup>
278
+ )}
279
+ <option value="upload">+ Upload CSV...</option>
280
+ <option value="upload_schema">+ Upload Relational DB...</option>
281
+ </select>
282
+ )}
283
+ </div>
284
+
285
+ {/* App Container */}
286
+ <div className="app-container" style={{ height: "calc(100vh - 40px)", display: "flex", flexGrow: 1 }}>
287
+ {/* Sidebar (Left Panel) */}
288
+ <Sidebar
289
+ datasets={datasets}
290
+ schemaDatasets={schemaDatasets}
291
+ selectedDataset={selectedDataset}
292
+ onSelect={handleSelectDataset}
293
+ onUploadSuccess={handleUploadSuccess}
294
+ onUploadSchemaClick={() => setIsUploadModalOpen(true)}
295
+ onDelete={handleDeleteDataset}
296
+ history={history}
297
+ onRunQuestion={handleRunQuestion}
298
+ addToast={addToast}
299
+ />
300
+
301
+ {/* Main content Area (Right Panel) */}
302
+ <main className="main-content" style={{ flexGrow: 1, height: "100%", overflowY: "auto" }}>
303
+ {/* Top Navigation Bar (Only shown if a dataset is selected) */}
304
+ {selectedDataset && (
305
+ <div
306
+ style={{
307
+ display: "flex",
308
+ justifyContent: "space-between",
309
+ alignItems: "center",
310
+ marginBottom: "24px",
311
+ paddingBottom: "12px",
312
+ borderBottom: "1px solid var(--border-color)"
313
+ }}
314
+ >
315
+ <div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
316
+ <span style={{ fontSize: "1.1rem", fontWeight: 700, color: "var(--text-primary)" }}>
317
+ 📁 {selectedDataset.display_name}
318
+ </span>
319
+ <span
320
+ className={`badge ${
321
+ selectedDataset.mode === "schema"
322
+ ? "badge-user"
323
+ : (selectedDataset.is_sample ? "badge-sample" : "badge-user")
324
+ }`}
325
+ style={selectedDataset.mode === "schema" ? { backgroundColor: "rgba(139, 92, 246, 0.15)", color: "#a78bfa", borderColor: "rgba(139, 92, 246, 0.3)" } : {}}
326
+ >
327
+ {selectedDataset.mode === "schema" ? "Relational" : (selectedDataset.is_sample ? "Sample" : "User")}
328
+ </span>
329
+ </div>
330
+
331
+ {/* View Switch tabs */}
332
+ <div className="view-switch-tabs">
333
+ <button
334
+ className={`view-tab ${view === "preview" ? "active" : ""}`}
335
+ onClick={() => setView("preview")}
336
+ >
337
+ Data Preview
338
+ </button>
339
+ <button
340
+ className={`view-tab ${view === "query" ? "active" : ""}`}
341
+ onClick={() => setView("query")}
342
+ >
343
+ Query Studio
344
+ </button>
345
+ </div>
346
+ </div>
347
+ )}
348
+
349
+ {/* Global Loading Spinner overlay */}
350
+ {globalLoading ? (
351
+ <div className="loading-container" style={{ margin: "auto" }}>
352
+ <div className="spinner"></div>
353
+ <div style={{ color: "var(--text-secondary)", fontSize: "0.9rem" }}>
354
+ Loading database metadata and previews...
355
+ </div>
356
+ </div>
357
+ ) : (
358
+ /* Render Active View */
359
+ <>
360
+ {view === "upload" && (
361
+ <UploadView
362
+ onUploadSuccess={handleUploadSuccess}
363
+ onSchemaUploadSuccess={handleSchemaUploadSuccess}
364
+ />
365
+ )}
366
+
367
+ {view === "preview" && selectedDataset && selectedDataset.mode === "csv" && (
368
+ <DataPreview
369
+ dataset={selectedDataset}
370
+ onStartQuerying={handleStartQuerying}
371
+ />
372
+ )}
373
+
374
+ {view === "preview" && selectedDataset && selectedDataset.mode === "schema" && (
375
+ <SchemaPreviewPanel
376
+ dataset={selectedDataset}
377
+ onStartQuerying={handleStartQuerying}
378
+ addToast={addToast}
379
+ />
380
+ )}
381
+
382
+ {view === "query" && selectedDataset && selectedDataset.mode === "csv" && (
383
+ <QueryView
384
+ selectedDataset={selectedDataset}
385
+ schema={selectedDataset.columns}
386
+ onQuerySuccess={handleQuerySuccess}
387
+ activeQuestion={activeQuestion}
388
+ addToast={addToast}
389
+ />
390
+ )}
391
+
392
+ {view === "query" && selectedDataset && selectedDataset.mode === "schema" && (
393
+ <SchemaQueryView
394
+ selectedDataset={selectedDataset}
395
+ schemaInfo={selectedDataset.tables}
396
+ onQuerySuccess={handleQuerySuccess}
397
+ activeQuestion={activeQuestion}
398
+ addToast={addToast}
399
+ />
400
+ )}
401
+ </>
402
+ )}
403
+ </main>
404
+ </div>
405
+ </div>
406
+ );
407
+ }
408
+
409
+
frontend/src/api.js ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const BASE_URL = "http://localhost:8000";
2
+
3
+ /**
4
+ * Helper to process fetch responses, parsing JSON and raising errors with detailed messages if available.
5
+ */
6
+ async function handleResponse(response) {
7
+ if (!response.ok) {
8
+ let errMsg = `Request failed with status ${response.status}`;
9
+ try {
10
+ const data = await response.json();
11
+ if (data && data.detail) {
12
+ if (typeof data.detail === "string") {
13
+ errMsg = data.detail;
14
+ } else if (Array.isArray(data.detail)) {
15
+ // Parse FastAPI validation errors
16
+ errMsg = data.detail.map(err => `${err.loc.join(".")}: ${err.msg}`).join(", ");
17
+ }
18
+ }
19
+ } catch (e) {
20
+ // Fallback to text if parsing fails
21
+ try {
22
+ const text = await response.text();
23
+ if (text) errMsg = text;
24
+ } catch (innerErr) {}
25
+ }
26
+ throw new Error(errMsg);
27
+ }
28
+
29
+ // Return parsed data and preserve headers if needed
30
+ const jsonData = await response.json();
31
+
32
+ // If we have custom headers like X-Process-Time, we can attach it to the returned data structure
33
+ if (response.headers.has("X-Process-Time")) {
34
+ jsonData._processTime = response.headers.get("X-Process-Time");
35
+ }
36
+ return jsonData;
37
+ }
38
+
39
+ export async function getDatasets() {
40
+ console.log("[API] Fetching datasets...");
41
+ const response = await fetch(`${BASE_URL}/datasets`);
42
+ return handleResponse(response);
43
+ }
44
+
45
+ export async function getSchema(dbName) {
46
+ console.log(`[API] Fetching schema for database: ${dbName}...`);
47
+ const response = await fetch(`${BASE_URL}/datasets/${encodeURIComponent(dbName)}/schema`);
48
+ return handleResponse(response);
49
+ }
50
+
51
+ export async function getPreview(dbName) {
52
+ console.log(`[API] Fetching preview for database: ${dbName}...`);
53
+ const response = await fetch(`${BASE_URL}/datasets/${encodeURIComponent(dbName)}/preview`);
54
+ return handleResponse(response);
55
+ }
56
+
57
+ export async function uploadCSV(file) {
58
+ console.log(`[API] Uploading CSV file: ${file.name}...`);
59
+ const formData = new FormData();
60
+ formData.append("file", file);
61
+
62
+ const response = await fetch(`${BASE_URL}/upload`, {
63
+ method: "POST",
64
+ body: formData
65
+ // Note: Do not set Content-Type header manually. The browser will auto-set it with boundary.
66
+ });
67
+ return handleResponse(response);
68
+ }
69
+
70
+ export async function deleteDataset(dbName) {
71
+ console.log(`[API] Deleting database: ${dbName}...`);
72
+ const response = await fetch(`${BASE_URL}/datasets/${encodeURIComponent(dbName)}`, {
73
+ method: "DELETE"
74
+ });
75
+ return handleResponse(response);
76
+ }
77
+
78
+ export async function queryDataset(question, dbName) {
79
+ console.log(`[API] Querying database: ${dbName} with question: '${question}'...`);
80
+ const response = await fetch(`${BASE_URL}/query`, {
81
+ method: "POST",
82
+ headers: {
83
+ "Content-Type": "application/json"
84
+ },
85
+ body: JSON.stringify({ question, db_name: dbName })
86
+ });
87
+ return handleResponse(response);
88
+ }
89
+
90
+ export async function getSuggestions(dbName) {
91
+ console.log(`[API] Fetching suggestions for database: ${dbName}...`);
92
+ const response = await fetch(`${BASE_URL}/datasets/${encodeURIComponent(dbName)}/suggestions`);
93
+ return handleResponse(response);
94
+ }
95
+
96
+ export async function getQueryHistory(dbName) {
97
+ console.log(`[API] Fetching history for database: ${dbName}...`);
98
+ const response = await fetch(`${BASE_URL}/query/history?db_name=${encodeURIComponent(dbName)}`);
99
+ return handleResponse(response);
100
+ }
101
+
102
+ export async function getSchemaDatasets() {
103
+ console.log("[API] Fetching schema datasets...");
104
+ const response = await fetch(`${BASE_URL}/schema-datasets`);
105
+ return handleResponse(response);
106
+ }
107
+
108
+ export async function getSchemaInfo(dbName) {
109
+ console.log(`[API] Fetching schema details for database: ${dbName}...`);
110
+ const response = await fetch(`${BASE_URL}/schema-datasets/${encodeURIComponent(dbName)}/schema`);
111
+ return handleResponse(response);
112
+ }
113
+
114
+ export async function getSchemaPreview(dbName) {
115
+ console.log(`[API] Fetching schema preview for database: ${dbName}...`);
116
+ const response = await fetch(`${BASE_URL}/schema-datasets/${encodeURIComponent(dbName)}/preview`);
117
+ return handleResponse(response);
118
+ }
119
+
120
+ export async function uploadSchemaDB(sqlFile, erdImage = null) {
121
+ console.log(`[API] Uploading schema files...`);
122
+ const formData = new FormData();
123
+ if (sqlFile) {
124
+ formData.append("schema_file", sqlFile);
125
+ }
126
+ if (erdImage) {
127
+ formData.append("erd_image", erdImage);
128
+ }
129
+
130
+ const response = await fetch(`${BASE_URL}/upload/schema`, {
131
+ method: "POST",
132
+ body: formData
133
+ });
134
+ return handleResponse(response);
135
+ }
136
+
137
+ export async function deleteSchemaDataset(dbName) {
138
+ console.log(`[API] Deleting schema database: ${dbName}...`);
139
+ const response = await fetch(`${BASE_URL}/schema-datasets/${encodeURIComponent(dbName)}`, {
140
+ method: "DELETE"
141
+ });
142
+ return handleResponse(response);
143
+ }
144
+
145
+ export async function querySchema(question, dbName) {
146
+ console.log(`[API] Querying schema database: ${dbName} with question: '${question}'...`);
147
+ const response = await fetch(`${BASE_URL}/schema-query`, {
148
+ method: "POST",
149
+ headers: {
150
+ "Content-Type": "application/json"
151
+ },
152
+ body: JSON.stringify({ question, db_name: dbName })
153
+ });
154
+ return handleResponse(response);
155
+ }
156
+
157
+
frontend/src/assets/hero.png ADDED
frontend/src/assets/react.svg ADDED
frontend/src/assets/vite.svg ADDED
frontend/src/components/ChartView.jsx ADDED
@@ -0,0 +1,550 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useMemo } from "react";
2
+
3
+ // Helper checking if >80% of values are numbers
4
+ export function isNumeric(values) {
5
+ if (!values || values.length === 0) return false;
6
+ const numCount = values.filter(v => typeof v === "number" && !isNaN(v)).length;
7
+ return (numCount / values.length) > 0.8;
8
+ }
9
+
10
+ // Helper checking if >80% of values match YYYY-MM-DD format
11
+ export function looksLikeDate(values) {
12
+ if (!values || values.length === 0) return false;
13
+ const dateRegex = /^\d{4}-\d{2}-\d{2}/;
14
+ const dateCount = values.filter(v => typeof v === "string" && dateRegex.test(v)).length;
15
+ return (dateCount / values.length) > 0.8;
16
+ }
17
+
18
+ export default function ChartView({ result }) {
19
+ const [mounted, setMounted] = useState(false);
20
+ const [hoveredIdx, setHoveredIdx] = useState(null);
21
+
22
+ useEffect(() => {
23
+ const timer = setTimeout(() => setMounted(true), 50);
24
+ return () => clearTimeout(timer);
25
+ }, [result]);
26
+
27
+ const chartMeta = useMemo(() => {
28
+ if (!result || !result.results || !result.results.columns || !result.results.rows || result.results.rows.length === 0) {
29
+ return null;
30
+ }
31
+
32
+ const columns = result.results.columns;
33
+ const rows = result.results.rows;
34
+
35
+ let chartType = null;
36
+ let xIdx = 0;
37
+ let yIdx = 1;
38
+
39
+ // Detect column roles
40
+ if (columns.length >= 2) {
41
+ const col1Values = rows.map(r => r[0]);
42
+ const col2Values = rows.map(r => r[1]);
43
+
44
+ const col1IsNumeric = isNumeric(col1Values);
45
+ const col2IsNumeric = isNumeric(col2Values);
46
+ const col1IsDate = looksLikeDate(col1Values);
47
+
48
+ if (!col1IsNumeric && col2IsNumeric) {
49
+ xIdx = 0;
50
+ yIdx = 1;
51
+ if (col1IsDate) {
52
+ chartType = "LINE";
53
+ } else if (rows.length <= 6) {
54
+ chartType = "PIE";
55
+ } else {
56
+ chartType = "BAR";
57
+ }
58
+ } else if (col1IsNumeric && !col2IsNumeric) {
59
+ xIdx = 1;
60
+ yIdx = 0;
61
+ const col2IsDate = looksLikeDate(col2Values);
62
+ if (col2IsDate) {
63
+ chartType = "LINE";
64
+ } else if (rows.length <= 6) {
65
+ chartType = "PIE";
66
+ } else {
67
+ chartType = "BAR";
68
+ }
69
+ } else if (columns.length >= 3) {
70
+ // 3+ columns. Look for first text/date and first numeric
71
+ let foundX = -1;
72
+ let foundY = -1;
73
+ for (let i = 0; i < columns.length; i++) {
74
+ const vals = rows.map(r => r[i]);
75
+ if (isNumeric(vals)) {
76
+ if (foundY === -1) foundY = i;
77
+ } else {
78
+ if (foundX === -1) foundX = i;
79
+ }
80
+ }
81
+ if (foundX !== -1 && foundY !== -1) {
82
+ xIdx = foundX;
83
+ yIdx = foundY;
84
+ chartType = "BAR";
85
+ }
86
+ }
87
+ }
88
+
89
+ if (!chartType) return null;
90
+
91
+ // Format data points (limit to first 20 rows for layout readability)
92
+ const points = rows.slice(0, 20).map((row, idx) => {
93
+ const rawX = row[xIdx];
94
+ const rawY = row[yIdx];
95
+ return {
96
+ xVal: rawX === null || rawX === undefined ? "NULL" : String(rawX),
97
+ yVal: Number(rawY || 0),
98
+ originalRowIdx: idx
99
+ };
100
+ });
101
+
102
+ return {
103
+ chartType,
104
+ points,
105
+ xLabel: columns[xIdx],
106
+ yLabel: columns[yIdx],
107
+ totalCount: rows.length
108
+ };
109
+ }, [result]);
110
+
111
+ if (!chartMeta) {
112
+ return (
113
+ <div
114
+ className="chart-container"
115
+ style={{
116
+ display: "flex",
117
+ flexDirection: "column",
118
+ justifyContent: "center",
119
+ alignItems: "center",
120
+ height: "220px",
121
+ backgroundColor: "#161616",
122
+ border: "1px solid var(--border-color)",
123
+ borderRadius: "8px",
124
+ color: "var(--text-secondary)"
125
+ }}
126
+ >
127
+ <div style={{ backgroundColor: "#1e1e1e", padding: "12px", borderRadius: "50%", marginBottom: "12px" }}>
128
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
129
+ <circle cx="12" cy="12" r="10"></circle>
130
+ <line x1="12" y1="16" x2="12" y2="12"></line>
131
+ <line x1="12" y1="8" x2="12.01" y2="8"></line>
132
+ </svg>
133
+ </div>
134
+ <span style={{ fontSize: "0.9rem", fontWeight: 500, color: "var(--text-primary)" }}>Chart not available for this query type.</span>
135
+ <span style={{ fontSize: "0.8rem", color: "var(--text-muted)", marginTop: "4px" }}>View data in the Table tab.</span>
136
+ </div>
137
+ );
138
+ }
139
+
140
+ const { chartType, points, xLabel, yLabel, totalCount } = chartMeta;
141
+
142
+ // Format large numbers cleanly
143
+ const formatNumber = (num) => {
144
+ if (Math.abs(num) >= 1_000_000) return (num / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
145
+ if (Math.abs(num) >= 1_000) return (num / 1_000).toFixed(1).replace(/\.0$/, "") + "K";
146
+ if (num % 1 !== 0) return num.toFixed(2);
147
+ return num.toString();
148
+ };
149
+
150
+ // --- 1. BAR CHART RENDERER ---
151
+ if (chartType === "BAR") {
152
+ const svgWidth = 600;
153
+ const svgHeight = 350;
154
+ const padding = { top: 40, right: 30, bottom: 60, left: 65 };
155
+ const graphWidth = svgWidth - padding.left - padding.right;
156
+ const graphHeight = svgHeight - padding.top - padding.bottom;
157
+
158
+ const yValues = points.map(p => p.yVal);
159
+ const maxY = Math.max(...yValues, 0);
160
+ const minY = Math.min(...yValues, 0);
161
+ const upperLimit = maxY === 0 ? 10 : maxY * 1.15; // 15% top padding
162
+
163
+ // Grid ticks (5 marks)
164
+ const tickCount = 4;
165
+ const ticks = Array.from({ length: tickCount + 1 }).map((_, i) => {
166
+ const val = (upperLimit / tickCount) * i;
167
+ const y = padding.top + graphHeight - (val / upperLimit) * graphHeight;
168
+ return { val, y };
169
+ });
170
+
171
+ const colWidth = graphWidth / points.length;
172
+ const barPadding = 0.35;
173
+ const barWidth = colWidth * (1 - barPadding);
174
+ const barOffset = colWidth * (barPadding / 2);
175
+
176
+ return (
177
+ <div className="chart-container" style={{ height: "360px", padding: "10px" }}>
178
+ <svg viewBox={`0 0 ${svgWidth} ${svgHeight}`} width="100%" height="100%">
179
+ {/* Grid lines */}
180
+ {ticks.map((t, idx) => (
181
+ <g key={idx}>
182
+ <line x1={padding.left} y1={t.y} x2={svgWidth - padding.right} y2={t.y} stroke="#2c2c2c" strokeDasharray="3,3" />
183
+ <text x={padding.left - 10} y={t.y + 4} textAnchor="end" fill="var(--text-secondary)" fontSize="10" fontFamily="var(--font-sans)">
184
+ {formatNumber(t.val)}
185
+ </text>
186
+ </g>
187
+ ))}
188
+
189
+ {/* Bars */}
190
+ {points.map((pt, idx) => {
191
+ const x = padding.left + idx * colWidth + barOffset;
192
+ const barHeightVal = (pt.yVal / upperLimit) * graphHeight;
193
+
194
+ // Mount animation height
195
+ const animatedHeight = mounted ? barHeightVal : 0;
196
+ const y = padding.top + graphHeight - animatedHeight;
197
+
198
+ // Shorten label
199
+ const displayLabel = pt.xVal.length > 10 ? pt.xVal.substring(0, 10) + "..." : pt.xVal;
200
+ const isHovered = hoveredIdx === idx;
201
+
202
+ return (
203
+ <g
204
+ key={idx}
205
+ onMouseEnter={() => setHoveredIdx(idx)}
206
+ onMouseLeave={() => setHoveredIdx(null)}
207
+ >
208
+ {/* Bar */}
209
+ <rect
210
+ x={x}
211
+ y={y}
212
+ width={barWidth}
213
+ height={Math.max(animatedHeight, 1)}
214
+ rx="3"
215
+ fill={isHovered ? "#60a5fa" : "#3b82f6"}
216
+ style={{ transition: "height 0.4s ease-out, y 0.4s ease-out, fill 0.2s" }}
217
+ />
218
+
219
+ {/* Label on top of bar */}
220
+ {mounted && isHovered && (
221
+ <text
222
+ x={x + barWidth / 2}
223
+ y={y - 6}
224
+ textAnchor="middle"
225
+ fill="var(--text-primary)"
226
+ fontSize="10"
227
+ fontWeight="bold"
228
+ >
229
+ {pt.yVal.toLocaleString()}
230
+ </text>
231
+ )}
232
+
233
+ {/* X axis labels (slanted for readability) */}
234
+ <text
235
+ x={x + barWidth / 2}
236
+ y={padding.top + graphHeight + 15}
237
+ textAnchor="end"
238
+ fill="var(--text-secondary)"
239
+ fontSize="9"
240
+ fontFamily="var(--font-sans)"
241
+ transform={`rotate(-25, ${x + barWidth / 2}, ${padding.top + graphHeight + 15})`}
242
+ >
243
+ {displayLabel}
244
+ </text>
245
+ </g>
246
+ );
247
+ })}
248
+
249
+ {/* Axis lines */}
250
+ <line x1={padding.left} y1={padding.top + graphHeight} x2={svgWidth - padding.right} y2={padding.top + graphHeight} stroke="#2c2c2c" strokeWidth="1.5" />
251
+ <line x1={padding.left} y1={padding.top} x2={padding.left} y2={padding.top + graphHeight} stroke="#2c2c2c" strokeWidth="1.5" />
252
+
253
+ {/* Title labels */}
254
+ <text x={svgWidth / 2} y={18} textAnchor="middle" fill="var(--text-primary)" fontSize="12" fontWeight="600">
255
+ {yLabel} by {xLabel} {totalCount > 20 && <tspan fill="var(--text-muted)" fontSize="10">(first 20 rows)</tspan>}
256
+ </text>
257
+ </svg>
258
+ </div>
259
+ );
260
+ }
261
+
262
+ // --- 2. LINE CHART RENDERER ---
263
+ if (chartType === "LINE") {
264
+ const svgWidth = 600;
265
+ const svgHeight = 350;
266
+ const padding = { top: 40, right: 40, bottom: 50, left: 65 };
267
+ const graphWidth = svgWidth - padding.left - padding.right;
268
+ const graphHeight = svgHeight - padding.top - padding.bottom;
269
+
270
+ const yValues = points.map(p => p.yVal);
271
+ const maxY = Math.max(...yValues, 0);
272
+ const upperLimit = maxY === 0 ? 10 : maxY * 1.15;
273
+
274
+ // Grid ticks (5 marks)
275
+ const tickCount = 4;
276
+ const ticks = Array.from({ length: tickCount + 1 }).map((_, i) => {
277
+ const val = (upperLimit / tickCount) * i;
278
+ const y = padding.top + graphHeight - (val / upperLimit) * graphHeight;
279
+ return { val, y };
280
+ });
281
+
282
+ // Compute coordinates
283
+ const segmentWidth = graphWidth / Math.max(points.length - 1, 1);
284
+ const coords = points.map((pt, idx) => {
285
+ const x = padding.left + idx * segmentWidth;
286
+ const y = padding.top + graphHeight - (pt.yVal / upperLimit) * graphHeight;
287
+ return { x, y, pt, idx };
288
+ });
289
+
290
+ // Generate polyline string points
291
+ const polylinePoints = coords.map(c => `${c.x},${c.y}`).join(" ");
292
+
293
+ // Generate gradient fill area path points
294
+ const areaPath = coords.length > 0
295
+ ? `M ${coords[0].x},${padding.top + graphHeight} ` +
296
+ coords.map(c => `L ${c.x},${c.y}`).join(" ") +
297
+ ` L ${coords[coords.length - 1].x},${padding.top + graphHeight} Z`
298
+ : "";
299
+
300
+ return (
301
+ <div className="chart-container" style={{ height: "360px", padding: "10px" }}>
302
+ <svg viewBox={`0 0 ${svgWidth} ${svgHeight}`} width="100%" height="100%">
303
+ <defs>
304
+ <linearGradient id="blue-gradient" x1="0" y1="0" x2="0" y2="1">
305
+ <stop offset="0%" stopColor="#3b82f6" stopOpacity="0.45" />
306
+ <stop offset="100%" stopColor="#3b82f6" stopOpacity="0.0" />
307
+ </linearGradient>
308
+ </defs>
309
+
310
+ {/* Grid lines */}
311
+ {ticks.map((t, idx) => (
312
+ <g key={idx}>
313
+ <line x1={padding.left} y1={t.y} x2={svgWidth - padding.right} y2={t.y} stroke="#2c2c2c" strokeDasharray="3,3" />
314
+ <text x={padding.left - 10} y={t.y + 4} textAnchor="end" fill="var(--text-secondary)" fontSize="10" fontFamily="var(--font-sans)">
315
+ {formatNumber(t.val)}
316
+ </text>
317
+ </g>
318
+ ))}
319
+
320
+ {/* Area under the line */}
321
+ {mounted && areaPath && (
322
+ <path d={areaPath} fill="url(#blue-gradient)" style={{ transition: "all 0.5s ease-out" }} />
323
+ )}
324
+
325
+ {/* Line string */}
326
+ {mounted && polylinePoints && (
327
+ <polyline
328
+ fill="none"
329
+ stroke="#3b82f6"
330
+ strokeWidth="2.5"
331
+ points={polylinePoints}
332
+ style={{ strokeDasharray: "1000", strokeDashoffset: mounted ? "0" : "1000", transition: "stroke-dashoffset 1s ease-out" }}
333
+ />
334
+ )}
335
+
336
+ {/* Vertical crosshair on hover */}
337
+ {hoveredIdx !== null && coords[hoveredIdx] && (
338
+ <g>
339
+ <line
340
+ x1={coords[hoveredIdx].x}
341
+ y1={padding.top}
342
+ x2={coords[hoveredIdx].x}
343
+ y2={padding.top + graphHeight}
344
+ stroke="#60a5fa"
345
+ strokeWidth="1"
346
+ strokeDasharray="4,4"
347
+ />
348
+ {/* Tooltip */}
349
+ <rect
350
+ x={Math.max(coords[hoveredIdx].x - 65, padding.left)}
351
+ y={Math.max(coords[hoveredIdx].y - 45, 10)}
352
+ width="130"
353
+ height="32"
354
+ rx="4"
355
+ fill="#1f1f1f"
356
+ stroke="#3b82f6"
357
+ strokeWidth="1"
358
+ />
359
+ <text
360
+ x={Math.max(coords[hoveredIdx].x, padding.left + 65)}
361
+ y={Math.max(coords[hoveredIdx].y - 32, 23)}
362
+ textAnchor="middle"
363
+ fill="var(--text-primary)"
364
+ fontSize="9"
365
+ fontWeight="bold"
366
+ >
367
+ {coords[hoveredIdx].pt.xVal}
368
+ </text>
369
+ <text
370
+ x={Math.max(coords[hoveredIdx].x, padding.left + 65)}
371
+ y={Math.max(coords[hoveredIdx].y - 20, 35)}
372
+ textAnchor="middle"
373
+ fill="#3b82f6"
374
+ fontSize="9"
375
+ fontWeight="bold"
376
+ >
377
+ {coords[hoveredIdx].pt.yVal.toLocaleString()}
378
+ </text>
379
+ </g>
380
+ )}
381
+
382
+ {/* Data point dots and hover trigger zones */}
383
+ {coords.map((c, idx) => (
384
+ <g key={idx}>
385
+ <circle cx={c.x} cy={c.y} r="4" fill="#3b82f6" stroke="#0d0d0d" strokeWidth="1" />
386
+ {/* Invisible large target for easier hover */}
387
+ <circle
388
+ cx={c.x}
389
+ cy={c.y}
390
+ r="16"
391
+ fill="transparent"
392
+ style={{ cursor: "pointer" }}
393
+ onMouseEnter={() => setHoveredIdx(idx)}
394
+ onMouseLeave={() => setHoveredIdx(null)}
395
+ />
396
+
397
+ {/* X axis labels (limited spacing) */}
398
+ {(points.length < 8 || idx % 2 === 0) && (
399
+ <text
400
+ x={c.x}
401
+ y={padding.top + graphHeight + 16}
402
+ textAnchor="middle"
403
+ fill="var(--text-secondary)"
404
+ fontSize="9"
405
+ fontFamily="var(--font-sans)"
406
+ >
407
+ {c.pt.xVal}
408
+ </text>
409
+ )}
410
+ </g>
411
+ ))}
412
+
413
+ {/* Axis lines */}
414
+ <line x1={padding.left} y1={padding.top + graphHeight} x2={svgWidth - padding.right} y2={padding.top + graphHeight} stroke="#2c2c2c" strokeWidth="1.5" />
415
+ <line x1={padding.left} y1={padding.top} x2={padding.left} y2={padding.top + graphHeight} stroke="#2c2c2c" strokeWidth="1.5" />
416
+
417
+ {/* Title labels */}
418
+ <text x={svgWidth / 2} y={18} textAnchor="middle" fill="var(--text-primary)" fontSize="12" fontWeight="600">
419
+ {yLabel} trend over {xLabel} {totalCount > 20 && <tspan fill="var(--text-muted)" fontSize="10">(first 20 rows)</tspan>}
420
+ </text>
421
+ </svg>
422
+ </div>
423
+ );
424
+ }
425
+
426
+ // --- 3. PIE CHART RENDERER ---
427
+ if (chartType === "PIE") {
428
+ const svgWidth = 400;
429
+ const svgHeight = 300;
430
+ const cx = 130;
431
+ const cy = 150;
432
+ const r = 95;
433
+ const colors = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899"];
434
+
435
+ const yValues = points.map(p => p.yVal);
436
+ const sum = yValues.reduce((a, b) => a + b, 0);
437
+
438
+ let currentAngle = -Math.PI / 2; // Start at 12 o'clock
439
+
440
+ const slices = points.map((pt, idx) => {
441
+ const val = pt.yVal;
442
+ const pct = sum === 0 ? 0 : val / sum;
443
+ const angle = pct * 2 * Math.PI;
444
+ const startAngle = currentAngle;
445
+ const endAngle = currentAngle + angle;
446
+ currentAngle = endAngle;
447
+
448
+ const midAngle = (startAngle + endAngle) / 2;
449
+ return {
450
+ pt,
451
+ pct,
452
+ startAngle,
453
+ endAngle,
454
+ midAngle,
455
+ color: colors[idx % colors.length],
456
+ idx
457
+ };
458
+ });
459
+
460
+ return (
461
+ <div className="chart-container" style={{ height: "320px", padding: "10px" }}>
462
+ <svg viewBox={`0 0 ${svgWidth} ${svgHeight}`} width="100%" height="100%">
463
+ {/* Slices */}
464
+ {sum > 0 ? (
465
+ slices.map((slice, idx) => {
466
+ const x1 = cx + r * Math.cos(slice.startAngle);
467
+ const y1 = cy + r * Math.sin(slice.startAngle);
468
+ const x2 = cx + r * Math.cos(slice.endAngle);
469
+ const y2 = cy + r * Math.sin(slice.endAngle);
470
+
471
+ const largeArcFlag = slice.pct > 0.5 ? 1 : 0;
472
+ const isHovered = hoveredIdx === idx;
473
+
474
+ // Arc path: Move to center, Line to start boundary, Arc to end boundary, close path
475
+ let d = `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArcFlag} 1 ${x2} ${y2} Z`;
476
+
477
+ if (slice.pct >= 0.999) {
478
+ // Draw a circle if single slice dominates
479
+ d = `M ${cx} ${cy - r} A ${r} ${r} 0 1 1 ${cx - 0.01} ${cy - r} Z`;
480
+ }
481
+
482
+ // Displace slice on hover
483
+ const dx = isHovered ? 8 * Math.cos(slice.midAngle) : 0;
484
+ const dy = isHovered ? 8 * Math.sin(slice.midAngle) : 0;
485
+
486
+ return (
487
+ <path
488
+ key={idx}
489
+ d={d}
490
+ fill={slice.color}
491
+ stroke="#0d0d0d"
492
+ strokeWidth="1.5"
493
+ onMouseEnter={() => setHoveredIdx(idx)}
494
+ onMouseLeave={() => setHoveredIdx(null)}
495
+ style={{
496
+ transform: `translate(${dx}px, ${dy}px)`,
497
+ transition: "transform 0.2s ease-out",
498
+ cursor: "pointer"
499
+ }}
500
+ />
501
+ );
502
+ })
503
+ ) : (
504
+ <circle cx={cx} cy={cy} r={r} fill="#2c2c2c" />
505
+ )}
506
+
507
+ {/* Legend keys on the right */}
508
+ <g transform="translate(255, 60)">
509
+ {slices.map((slice, idx) => {
510
+ const displayLabel = slice.pt.xVal.length > 12
511
+ ? slice.pt.xVal.substring(0, 12) + "..."
512
+ : slice.pt.xVal;
513
+ const pctText = (slice.pct * 100).toFixed(1) + "%";
514
+ const isHovered = hoveredIdx === idx;
515
+
516
+ return (
517
+ <g
518
+ key={idx}
519
+ transform={`translate(0, ${idx * 22})`}
520
+ style={{ cursor: "pointer" }}
521
+ onMouseEnter={() => setHoveredIdx(idx)}
522
+ onMouseLeave={() => setHoveredIdx(null)}
523
+ >
524
+ <circle cx="5" cy="5" r="5" fill={slice.color} />
525
+ <text
526
+ x="16"
527
+ y="9"
528
+ fill={isHovered ? "var(--text-primary)" : "var(--text-secondary)"}
529
+ fontSize="10.5"
530
+ fontFamily="var(--font-sans)"
531
+ fontWeight={isHovered ? "bold" : "normal"}
532
+ >
533
+ {displayLabel} ({pctText})
534
+ </text>
535
+ </g>
536
+ );
537
+ })}
538
+ </g>
539
+
540
+ {/* Title labels */}
541
+ <text x={svgWidth / 2} y={22} textAnchor="middle" fill="var(--text-primary)" fontSize="12" fontWeight="600">
542
+ {yLabel} share by {xLabel}
543
+ </text>
544
+ </svg>
545
+ </div>
546
+ );
547
+ }
548
+
549
+ return null;
550
+ }
frontend/src/components/DataPreview.jsx ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import DataTable from "./DataTable";
3
+
4
+ export default function DataPreview({ dataset, onStartQuerying }) {
5
+ if (!dataset) return null;
6
+
7
+ // Extract columns (schema) and rows (preview data)
8
+ const columnsSchema = dataset.columns || [];
9
+ const previewData = dataset.preview || { columns: [], rows: [] };
10
+ const displayDescription = dataset.description || `Database containing ${dataset.display_name} tables.`;
11
+
12
+ const getTypeBadgeClass = (type) => {
13
+ const t = type ? type.toUpperCase() : "TEXT";
14
+ if (t === "INTEGER" || t === "REAL") return "badge-type integer";
15
+ if (t === "DATE" || t === "TIMESTAMP" || t === "DATETIME") return "badge-type date";
16
+ return "badge-type text";
17
+ };
18
+
19
+ return (
20
+ <div style={{ display: "flex", flexDirection: "column", gap: "28px" }}>
21
+ {/* 1. Dataset Info Card */}
22
+ <div className="preview-header">
23
+ <div className="preview-title-area">
24
+ <h1>{dataset.display_name}</h1>
25
+ <p className="preview-description">{displayDescription}</p>
26
+ <div className="stat-badges">
27
+ <div className="stat-badge">
28
+ <span>Total Rows:</span>
29
+ <span>{dataset.row_count}</span>
30
+ </div>
31
+ <div className="stat-badge">
32
+ <span>Columns:</span>
33
+ <span>{columnsSchema.length}</span>
34
+ </div>
35
+ <div className="stat-badge">
36
+ <span>Format:</span>
37
+ <span>SQLite</span>
38
+ </div>
39
+ </div>
40
+ </div>
41
+
42
+ {/* Start Querying button */}
43
+ <button className="btn-primary" onClick={onStartQuerying}>
44
+ Start Asking Questions →
45
+ </button>
46
+ </div>
47
+
48
+ {/* 2. Schema Table Section */}
49
+ <div>
50
+ <h2 className="section-title">Database Schema</h2>
51
+ <div className="table-container">
52
+ <table className="table-preview">
53
+ <thead>
54
+ <tr>
55
+ <th>Column Name</th>
56
+ <th>Data Type</th>
57
+ <th>Sample Values</th>
58
+ </tr>
59
+ </thead>
60
+ <tbody>
61
+ {columnsSchema.map((col, idx) => (
62
+ <tr key={idx}>
63
+ <td className="cell-mono" style={{ fontWeight: 600 }}>{col.name}</td>
64
+ <td>
65
+ <span className={`badge ${getTypeBadgeClass(col.type)}`}>
66
+ {col.type}
67
+ </span>
68
+ </td>
69
+ <td className="cell-mono" style={{ color: "var(--text-secondary)" }}>
70
+ {col.sample_values && col.sample_values.length > 0
71
+ ? col.sample_values.map(val => (val === null ? "null" : String(val))).join(", ")
72
+ : "No examples available"}
73
+ </td>
74
+ </tr>
75
+ ))}
76
+ </tbody>
77
+ </table>
78
+ </div>
79
+ </div>
80
+
81
+ {/* 3. Data Preview Section (first 10 rows) */}
82
+ <div>
83
+ <h2 className="section-title">Data Preview (first 10 rows)</h2>
84
+ <DataTable
85
+ columns={previewData.columns}
86
+ rows={previewData.rows}
87
+ maxHeight="320px"
88
+ />
89
+ </div>
90
+
91
+ {/* Bottom CTA Button */}
92
+ <div style={{ marginTop: "12px", display: "flex", justifyContent: "flex-end" }}>
93
+ <button className="btn-primary" onClick={onStartQuerying}>
94
+ Start Asking Questions →
95
+ </button>
96
+ </div>
97
+ </div>
98
+ );
99
+ }
frontend/src/components/DataTable.jsx ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+
3
+ export default function DataTable({ columns, rows, maxHeight }) {
4
+ if (!columns || columns.length === 0) {
5
+ return <div style={{ color: "var(--text-muted)", fontSize: "0.85rem", padding: "10px" }}>No data columns.</div>;
6
+ }
7
+
8
+ const isNumeric = (val) => {
9
+ return typeof val === "number" && !isNaN(val);
10
+ };
11
+
12
+ return (
13
+ <div
14
+ className="table-container"
15
+ style={maxHeight ? { maxHeight: maxHeight } : {}}
16
+ >
17
+ <table className="table-preview">
18
+ <thead>
19
+ <tr>
20
+ {columns.map((col, idx) => (
21
+ <th key={idx}>{col}</th>
22
+ ))}
23
+ </tr>
24
+ </thead>
25
+ <tbody>
26
+ {rows && rows.length > 0 ? (
27
+ rows.map((row, rowIdx) => (
28
+ <tr key={rowIdx}>
29
+ {row.map((cell, cellIdx) => {
30
+ const numeric = isNumeric(cell);
31
+ return (
32
+ <td
33
+ key={cellIdx}
34
+ className={`cell-mono ${numeric ? "cell-numeric" : "cell-text"}`}
35
+ >
36
+ {cell === null || cell === undefined ? (
37
+ <span style={{ color: "var(--text-muted)", fontStyle: "italic", fontSize: "0.75rem" }}>
38
+ NULL
39
+ </span>
40
+ ) : typeof cell === "boolean" ? (
41
+ cell.toString().toUpperCase()
42
+ ) : (
43
+ cell
44
+ )}
45
+ </td>
46
+ );
47
+ })}
48
+ </tr>
49
+ ))
50
+ ) : (
51
+ <tr>
52
+ <td
53
+ colSpan={columns.length}
54
+ style={{ textAlign: "center", color: "var(--text-muted)", padding: "24px" }}
55
+ >
56
+ No records returned.
57
+ </td>
58
+ </tr>
59
+ )}
60
+ </tbody>
61
+ </table>
62
+ </div>
63
+ );
64
+ }
frontend/src/components/LoadingResult.jsx ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+
3
+ export default function LoadingResult({ question, themeColor = "var(--primary-color)" }) {
4
+ return (
5
+ <div className="output-card shimmer-card" style={{ opacity: 0.85 }}>
6
+ {/* Question Header */}
7
+ <div style={{ display: "flex", alignItems: "flex-start", gap: "8px", borderBottom: "1px solid var(--border-color)", paddingBottom: "12px" }}>
8
+ <span style={{ color: themeColor, fontWeight: "bold", fontSize: "1.1rem" }}>Q:</span>
9
+ <div style={{ display: "flex", flexDirection: "column", gap: "4px", flexGrow: 1 }}>
10
+ <span style={{ fontSize: "1rem", fontWeight: 600 }}>{question}</span>
11
+ <span style={{ fontSize: "0.75rem", color: "var(--text-muted)" }}>Running query pipeline...</span>
12
+ </div>
13
+ </div>
14
+
15
+ {/* SQL block skeleton */}
16
+ <div style={{ display: "flex", flexDirection: "column", gap: "8px", marginTop: "12px" }}>
17
+ <div className="shimmer" style={{ height: "16px", width: "120px", borderRadius: "4px" }}></div>
18
+ <div className="shimmer" style={{ height: "60px", borderRadius: "6px" }}></div>
19
+ </div>
20
+
21
+ {/* Insight box skeleton */}
22
+ <div style={{ display: "flex", flexDirection: "column", gap: "8px", marginTop: "16px" }}>
23
+ <div className="shimmer" style={{ height: "16px", width: "80px", borderRadius: "4px" }}></div>
24
+ <div className="shimmer" style={{ height: "50px", borderRadius: "6px", backgroundColor: "rgba(30, 42, 58, 0.5)", borderLeft: `3px solid ${themeColor}` }}></div>
25
+ </div>
26
+
27
+ {/* Chart tab content area skeleton */}
28
+ <div style={{ display: "flex", flexDirection: "column", gap: "12px", marginTop: "16px" }}>
29
+ <div style={{ display: "flex", gap: "8px" }}>
30
+ <div className="shimmer" style={{ height: "28px", width: "80px", borderRadius: "4px" }}></div>
31
+ <div className="shimmer" style={{ height: "28px", width: "80px", borderRadius: "4px" }}></div>
32
+ </div>
33
+ <div className="shimmer" style={{ height: "180px", borderRadius: "6px" }}></div>
34
+ </div>
35
+
36
+ {/* Status Footer */}
37
+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: "16px", paddingTop: "12px", borderTop: "1px solid var(--border-color)", fontSize: "0.8rem", color: "var(--text-secondary)" }}>
38
+ <span>🤔 Generating SQL and running query...</span>
39
+ <div className="shimmer" style={{ height: "14px", width: "120px", borderRadius: "4px" }}></div>
40
+ </div>
41
+ </div>
42
+ );
43
+ }
44
+
frontend/src/components/QueryInput.jsx ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from "react";
2
+
3
+ export default function QueryInput({ onSubmit, loading, datasetName, schema, onArrowUp }) {
4
+ const [question, setQuestion] = useState("");
5
+
6
+ // Clear input when dataset changes
7
+ useEffect(() => {
8
+ setQuestion("");
9
+ }, [datasetName]);
10
+
11
+ const handleFormSubmit = (e) => {
12
+ if (e) e.preventDefault();
13
+ if (!question || question.trim().length < 3 || loading) return;
14
+
15
+ onSubmit(question.trim());
16
+ setQuestion("");
17
+ };
18
+
19
+ const handleKeyDown = (e) => {
20
+ // Enter key or Ctrl+Enter submits the form, but Shift+Enter adds a newline
21
+ if (e.key === "Enter" && !e.shiftKey) {
22
+ e.preventDefault();
23
+ handleFormSubmit();
24
+ } else if (e.key === "Enter" && e.ctrlKey) {
25
+ e.preventDefault();
26
+ handleFormSubmit();
27
+ }
28
+
29
+ // Arrow Up when input is empty fills with last question
30
+ if (e.key === "ArrowUp" && !question.trim()) {
31
+ if (onArrowUp) {
32
+ const lastQ = onArrowUp();
33
+ if (lastQ) {
34
+ e.preventDefault();
35
+ setQuestion(lastQ);
36
+ }
37
+ }
38
+ }
39
+ };
40
+
41
+ return (
42
+ <div className="query-input-section" style={{ borderTop: "1px solid var(--border-color)", padding: "16px 20px" }}>
43
+ <form onSubmit={handleFormSubmit} style={{ display: "flex", gap: "12px", alignItems: "flex-end" }}>
44
+ <div style={{ flexGrow: 1, position: "relative" }}>
45
+ <textarea
46
+ className="query-input"
47
+ style={{ width: "100%", height: "64px", resize: "none", display: "block" }}
48
+ value={question}
49
+ onChange={(e) => setQuestion(e.target.value)}
50
+ onKeyDown={handleKeyDown}
51
+ placeholder={`Ask a question about '${datasetName}' in plain English... (Ctrl+Enter to submit, Arrow Up for last question)`}
52
+ disabled={loading}
53
+ rows={2}
54
+ />
55
+ </div>
56
+
57
+ <button
58
+ type="submit"
59
+ className="btn-primary"
60
+ style={{ height: "48px", padding: "0 20px", display: "flex", alignItems: "center", gap: "6px" }}
61
+ disabled={loading || !question.trim() || question.trim().length < 3}
62
+ >
63
+ <span>Ask</span>
64
+ <svg
65
+ xmlns="http://www.w3.org/2000/svg"
66
+ width="16"
67
+ height="16"
68
+ viewBox="0 0 24 24"
69
+ fill="none"
70
+ stroke="currentColor"
71
+ strokeWidth="2.5"
72
+ strokeLinecap="round"
73
+ strokeLinejoin="round"
74
+ >
75
+ <line x1="22" y1="2" x2="11" y2="13"></line>
76
+ <polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
77
+ </svg>
78
+ </button>
79
+ </form>
80
+ </div>
81
+ );
82
+ }
83
+
frontend/src/components/QueryResult.jsx ADDED
@@ -0,0 +1,332 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from "react";
2
+ import ChartView from "./ChartView";
3
+ import DataTable from "./DataTable";
4
+
5
+ // Manual SQL Syntax Highlighter using regex rules that avoids matching inside HTML tags
6
+ function highlightSQL(sql) {
7
+ if (!sql) return "";
8
+
9
+ // Escape HTML tags to prevent XSS/rendering issues
10
+ let escaped = sql
11
+ .replace(/&/g, "&amp;")
12
+ .replace(/</g, "&lt;")
13
+ .replace(/>/g, "&gt;");
14
+
15
+ // Syntax highlighting rules
16
+ const strings = /(["'])(.*?)\1/g;
17
+
18
+ // Stash string literals to prevent nested highlighting
19
+ const savedStrings = [];
20
+ escaped = escaped.replace(strings, (match) => {
21
+ const placeholder = `__STRING_PLACEHOLDER_${savedStrings.length}__`;
22
+ savedStrings.push(match);
23
+ return placeholder;
24
+ });
25
+
26
+ // Highlight keywords - ignoring anything inside html tags by matching tags first and returning them as-is
27
+ escaped = escaped.replace(/(<[^>]+>)|(\b(SELECT|FROM|WHERE|GROUP BY|ORDER BY|LIMIT|JOIN|ON|AS|AND|OR|IN|SUM|AVG|COUNT|MIN|MAX|HAVING|DESC|ASC|LEFT|RIGHT|INNER|IS|NOT|NULL|LIKE)\b)/gi, (match, tag, kw) => {
28
+ if (tag) return tag;
29
+ return `<span style="color: #3b82f6; font-weight: 600;">${kw.toUpperCase()}</span>`;
30
+ });
31
+
32
+ // Highlight numbers - ignoring anything inside html tags
33
+ escaped = escaped.replace(/(<[^>]+>)|(\b\d+(?:\.\d+)?\b)/g, (match, tag, num) => {
34
+ if (tag) return tag;
35
+ return `<span style="color: #f59e0b;">${num}</span>`;
36
+ });
37
+
38
+ // Highlight table aliases - c., o., p., oi., r. in pink #f472b6
39
+ escaped = escaped.replace(/(<[^>]+>)|(\b(c|o|p|oi|r)\.)/gi, (match, tag, aliasWithDot, alias) => {
40
+ if (tag) return tag;
41
+ return `<span style="color: #f472b6; font-weight: 600;">${alias.toLowerCase()}</span>.`;
42
+ });
43
+
44
+ // Restore string literals
45
+ savedStrings.forEach((str, idx) => {
46
+ escaped = escaped.replace(`__STRING_PLACEHOLDER_${idx}__`, `<span style="color: #10b981;">${str}</span>`);
47
+ });
48
+
49
+ return escaped;
50
+
51
+ }
52
+
53
+ export default function QueryResult({ result }) {
54
+ const [sqlExpanded, setSqlExpanded] = useState(false);
55
+ const [copied, setCopied] = useState(false);
56
+ const [activeTab, setActiveTab] = useState("chart"); // chart | table
57
+ const [relativeTime, setRelativeTime] = useState("just now");
58
+
59
+ // Update relative timestamp periodic updates
60
+ useEffect(() => {
61
+ if (!result.timestamp) return;
62
+
63
+ const updateTime = () => {
64
+ const elapsedMs = new Date() - new Date(result.timestamp);
65
+ const elapsedMins = Math.floor(elapsedMs / 60000);
66
+ if (elapsedMins < 1) {
67
+ setRelativeTime("just now");
68
+ } else if (elapsedMins === 1) {
69
+ setRelativeTime("1 min ago");
70
+ } else {
71
+ setRelativeTime(`${elapsedMins} min ago`);
72
+ }
73
+ };
74
+
75
+ updateTime();
76
+ const interval = setInterval(updateTime, 30000); // update every 30s
77
+ return () => clearInterval(interval);
78
+ }, [result.timestamp]);
79
+
80
+ const handleCopySQL = () => {
81
+ if (!result.sql) return;
82
+ navigator.clipboard.writeText(result.sql);
83
+ setCopied(true);
84
+ setTimeout(() => setCopied(false), 1500);
85
+ };
86
+
87
+ const handleExportCSV = () => {
88
+ if (!result.results || !result.results.columns || !result.results.rows) return;
89
+
90
+ const cols = result.results.columns;
91
+ const rows = result.results.rows;
92
+
93
+ // Build CSV content
94
+ const headerRow = cols.map(c => '"' + c.replace(/"/g, '""') + '"').join(",");
95
+ const dataRows = rows.map(row =>
96
+ row.map(val => {
97
+ if (val === null || val === undefined) return '""';
98
+ return '"' + String(val).replace(/"/g, '""') + '"';
99
+ }).join(",")
100
+ );
101
+
102
+ const csvContent = [headerRow, ...dataRows].join("\n");
103
+ const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
104
+
105
+ // File name: "{db_name}_{question_first_4_words}.csv"
106
+ const words = result.question
107
+ .split(/\s+/)
108
+ .slice(0, 4)
109
+ .join("_")
110
+ .toLowerCase()
111
+ .replace(/[^a-z0-9_]/g, ""); // Clean filename
112
+
113
+ const filename = `${result.db_name || "dataset"}_${words || "export"}.csv`;
114
+
115
+ const url = URL.createObjectURL(blob);
116
+ const link = document.createElement("a");
117
+ link.setAttribute("href", url);
118
+ link.setAttribute("download", filename);
119
+ document.body.appendChild(link);
120
+ link.click();
121
+ document.body.removeChild(link);
122
+ };
123
+
124
+ return (
125
+ <div className="output-card" style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
126
+ {/* a. Question Header */}
127
+ <div
128
+ style={{
129
+ display: "flex",
130
+ justifyContent: "space-between",
131
+ alignItems: "flex-start",
132
+ borderBottom: "1px solid var(--border-color)",
133
+ paddingBottom: "12px"
134
+ }}
135
+ >
136
+ <div style={{ display: "flex", gap: "8px", alignItems: "flex-start" }}>
137
+ <span style={{ color: result.mode === "schema" ? "#8b5cf6" : "var(--primary-color)", fontWeight: "bold", fontSize: "1.1rem" }}>Q:</span>
138
+ <span style={{ fontSize: "1rem", fontWeight: 600 }}>{result.question}</span>
139
+ </div>
140
+ <span style={{ fontSize: "0.75rem", color: "var(--text-muted)", whiteSpace: "nowrap" }}>
141
+ {relativeTime}
142
+ </span>
143
+ </div>
144
+
145
+ {/* Tables Joined Banner (Schema Mode Flow) */}
146
+ {result.mode === "schema" && result.tables_used && result.tables_used.length > 0 && (
147
+ <div
148
+ style={{
149
+ backgroundColor: "#1a1025",
150
+ border: "1px solid rgba(139, 92, 246, 0.2)",
151
+ borderRadius: "8px",
152
+ padding: "10px 14px",
153
+ display: "flex",
154
+ alignItems: "center",
155
+ gap: "8px",
156
+ flexWrap: "wrap",
157
+ marginTop: "-6px"
158
+ }}
159
+ >
160
+ <span style={{ fontSize: "0.7rem", fontWeight: 700, color: "#a78bfa", textTransform: "uppercase", marginRight: "4px" }}>
161
+ 🔗 Tables Joined:
162
+ </span>
163
+ {result.tables_used.map((tbl, idx) => (
164
+ <React.Fragment key={tbl}>
165
+ <span
166
+ style={{
167
+ backgroundColor: "#2d1b69",
168
+ border: "1px solid #8b5cf6",
169
+ color: "#f9fafb",
170
+ borderRadius: "100px",
171
+ padding: "2px 10px",
172
+ fontSize: "0.7rem",
173
+ fontWeight: 600,
174
+ textTransform: "lowercase"
175
+ }}
176
+ >
177
+ {tbl}
178
+ </span>
179
+ {idx < result.tables_used.length - 1 && (
180
+ <span style={{ color: "#8b5cf6", fontSize: "0.75rem", fontWeight: "bold" }}>
181
+ &rarr;
182
+ </span>
183
+ )}
184
+ </React.Fragment>
185
+ ))}
186
+ </div>
187
+ )}
188
+
189
+ {/* b. SQL block */}
190
+ <div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
191
+ <button
192
+ className="btn-secondary"
193
+ onClick={() => setSqlExpanded(!sqlExpanded)}
194
+ style={{
195
+ display: "flex",
196
+ justifyContent: "space-between",
197
+ alignItems: "center",
198
+ padding: "8px 12px",
199
+ fontSize: "0.8rem",
200
+ width: "100%",
201
+ textAlign: "left"
202
+ }}
203
+ >
204
+ <span style={{ display: "flex", alignItems: "center", gap: "6px" }}>
205
+ <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
206
+ <polyline points="16 18 22 12 16 6"></polyline>
207
+ <polyline points="8 6 2 12 8 18"></polyline>
208
+ </svg>
209
+ Generated SQL
210
+ </span>
211
+ <span>{sqlExpanded ? "▲ Hide" : "▼ Show"}</span>
212
+ </button>
213
+
214
+ {sqlExpanded && (
215
+ <div style={{ position: "relative", marginTop: "4px" }}>
216
+ {/* Copy Button */}
217
+ <button
218
+ onClick={handleCopySQL}
219
+ style={{
220
+ position: "absolute",
221
+ top: "8px",
222
+ right: "8px",
223
+ backgroundColor: "#161616",
224
+ border: "1px solid var(--border-color)",
225
+ color: copied ? "var(--success-color)" : "var(--text-secondary)",
226
+ borderRadius: "4px",
227
+ padding: "4px 8px",
228
+ fontSize: "0.7rem",
229
+ cursor: "pointer",
230
+ fontWeight: "600",
231
+ transition: "all 0.2s"
232
+ }}
233
+ >
234
+ {copied ? "Copied!" : "Copy SQL"}
235
+ </button>
236
+ <pre
237
+ className="code-block"
238
+ style={{ backgroundColor: "#0a0a0a", margin: 0, paddingRight: "80px" }}
239
+ dangerouslySetInnerHTML={{ __html: highlightSQL(result.sql) }}
240
+ />
241
+ </div>
242
+ )}
243
+
244
+ {/* Retry Warnings */}
245
+ {result.retries > 0 && (
246
+ <span style={{ fontSize: "0.75rem", color: "var(--warning-color)", fontWeight: "500", display: "flex", alignItems: "center", gap: "4px", marginTop: "2px" }}>
247
+ ⚠️ Required {result.retries} retries to generate valid SQL.
248
+ </span>
249
+ )}
250
+ </div>
251
+
252
+ {/* c. Insight Box */}
253
+ {result.insight && (
254
+ <div
255
+ style={{
256
+ backgroundColor: "#1e2a3a",
257
+ borderLeft: "3px solid var(--primary-color)",
258
+ borderRadius: "0 6px 6px 0",
259
+ padding: "12px 16px",
260
+ fontSize: "0.9rem",
261
+ lineHeight: "1.5"
262
+ }}
263
+ >
264
+ <div style={{ fontWeight: 700, display: "flex", alignItems: "center", gap: "6px", marginBottom: "4px", color: "#60a5fa" }}>
265
+ <span>💡</span> Insight
266
+ </div>
267
+ <p style={{ color: "var(--text-primary)" }}>{result.insight}</p>
268
+ </div>
269
+ )}
270
+
271
+ {/* d. Results Tabs */}
272
+ <div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
273
+ <div className="view-switch-tabs" style={{ alignSelf: "flex-start" }}>
274
+ <button
275
+ className={`view-tab ${activeTab === "chart" ? "active" : ""}`}
276
+ onClick={() => setActiveTab("chart")}
277
+ >
278
+ 📊 Chart
279
+ </button>
280
+ <button
281
+ className={`view-tab ${activeTab === "table" ? "active" : ""}`}
282
+ onClick={() => setActiveTab("table")}
283
+ >
284
+ 📋 Table
285
+ </button>
286
+ </div>
287
+
288
+ {/* Dynamic content rendering based on activeTab */}
289
+ <div style={{ minHeight: "220px" }}>
290
+ {activeTab === "chart" ? (
291
+ <ChartView result={result} />
292
+ ) : (
293
+ <DataTable columns={result.results.columns} rows={result.results.rows} maxHeight="300px" />
294
+ )}
295
+ </div>
296
+ </div>
297
+
298
+ {/* g. Footer statistics & Export */}
299
+ <div
300
+ style={{
301
+ display: "flex",
302
+ justifyContent: "space-between",
303
+ alignItems: "center",
304
+ marginTop: "8px",
305
+ paddingTop: "12px",
306
+ borderTop: "1px solid var(--border-color)",
307
+ fontSize: "0.8rem",
308
+ color: "var(--text-secondary)"
309
+ }}
310
+ >
311
+ <div style={{ display: "flex", gap: "12px" }}>
312
+ <span>{result.results.row_count} rows returned</span>
313
+ <span style={{ color: "var(--border-color)" }}>|</span>
314
+ <span>Executed in {result.results.execution_time_ms}ms</span>
315
+ </div>
316
+
317
+ <button
318
+ className="btn-secondary"
319
+ onClick={handleExportCSV}
320
+ style={{ padding: "6px 12px", fontSize: "0.75rem", display: "flex", alignItems: "center", gap: "4px" }}
321
+ >
322
+ <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
323
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
324
+ <polyline points="7 10 12 15 17 10"></polyline>
325
+ <line x1="12" y1="15" x2="12" y2="3"></line>
326
+ </svg>
327
+ Export CSV
328
+ </button>
329
+ </div>
330
+ </div>
331
+ );
332
+ }
frontend/src/components/QueryView.jsx ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useRef } from "react";
2
+ import QueryInput from "./QueryInput";
3
+ import QueryResult from "./QueryResult";
4
+ import LoadingResult from "./LoadingResult";
5
+ import { queryDataset, getSuggestions } from "../api";
6
+
7
+ export default function QueryView({ selectedDataset, schema, onQuerySuccess, activeQuestion, addToast }) {
8
+ const [queryHistory, setQueryHistory] = useState([]);
9
+ const [loading, setLoading] = useState(false);
10
+ const [error, setError] = useState(null);
11
+
12
+ // Dynamic suggestions states
13
+ const [suggestions, setSuggestions] = useState([]);
14
+ const [suggestionsLoading, setSuggestionsLoading] = useState(false);
15
+
16
+ const historyEndRef = useRef(null);
17
+
18
+ // 1. Clear history & Fetch suggestions when selected dataset changes
19
+ useEffect(() => {
20
+ setQueryHistory([]);
21
+ setError(null);
22
+ setLoading(false);
23
+ setSuggestions([]);
24
+
25
+ const fetchSuggestions = async () => {
26
+ setSuggestionsLoading(true);
27
+ try {
28
+ console.log("[QueryView] Loading suggestions for database:", selectedDataset.db_name);
29
+ const res = await getSuggestions(selectedDataset.db_name);
30
+ setSuggestions(res.suggestions || []);
31
+ } catch (err) {
32
+ console.error("[QueryView] Failed to fetch dynamic suggestions:", err);
33
+ } finally {
34
+ setSuggestionsLoading(false);
35
+ }
36
+ };
37
+
38
+ if (selectedDataset) {
39
+ fetchSuggestions();
40
+ }
41
+ }, [selectedDataset]);
42
+
43
+ // 2. Scroll to bottom when history grows
44
+ useEffect(() => {
45
+ if (historyEndRef.current) {
46
+ historyEndRef.current.scrollIntoView({ behavior: "smooth" });
47
+ }
48
+ }, [queryHistory]);
49
+
50
+ // 3. Register global keyboard shortcut Ctrl+K to focus query input from anywhere
51
+ useEffect(() => {
52
+ const handleGlobalKeyDown = (e) => {
53
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
54
+ e.preventDefault();
55
+ const inputEl = document.querySelector(".query-input");
56
+ if (inputEl) {
57
+ inputEl.focus();
58
+ inputEl.select(); // Highlight any existing text inside
59
+ }
60
+ }
61
+ };
62
+ window.addEventListener("keydown", handleGlobalKeyDown);
63
+ return () => window.removeEventListener("keydown", handleGlobalKeyDown);
64
+ }, []);
65
+
66
+ // 4. Trigger query from external prop (like sidebar recent queries)
67
+ useEffect(() => {
68
+ if (activeQuestion && activeQuestion.text && activeQuestion.timestamp) {
69
+ handleQuerySubmit(activeQuestion.text);
70
+ }
71
+ }, [activeQuestion]);
72
+
73
+ const handleQuerySubmit = async (questionText) => {
74
+ setError(null);
75
+ setLoading(true);
76
+
77
+ const pendingId = "pending_" + Date.now();
78
+ const pendingEntry = {
79
+ id: pendingId,
80
+ question: questionText,
81
+ timestamp: new Date(),
82
+ isPending: true
83
+ };
84
+
85
+ setQueryHistory((prev) => [...prev, pendingEntry]);
86
+
87
+ try {
88
+ console.log(`[QueryView] Executing query: '${questionText}' on '${selectedDataset.db_name}'`);
89
+ const res = await queryDataset(questionText, selectedDataset.db_name);
90
+
91
+ // Update entry with real execution results
92
+ setQueryHistory((prev) =>
93
+ prev.map((item) =>
94
+ item.id === pendingId
95
+ ? { ...res, id: pendingId, timestamp: pendingEntry.timestamp }
96
+ : item
97
+ )
98
+ );
99
+ setLoading(false);
100
+ addToast("Query completed", "success");
101
+ if (onQuerySuccess) {
102
+ onQuerySuccess();
103
+ }
104
+ } catch (err) {
105
+ console.error("[QueryView] Query pipeline error:", err);
106
+ setError(err.message || "Failed to execute query.");
107
+ setLoading(false);
108
+ addToast("Query failed: " + err.message, "error");
109
+ // Remove loading block
110
+ setQueryHistory((prev) => prev.filter((item) => item.id !== pendingId));
111
+ }
112
+ };
113
+
114
+ const getLastQuestion = () => {
115
+ if (queryHistory.length === 0) return null;
116
+ // Get last non-pending query question
117
+ const completedQueries = queryHistory.filter(h => !h.isPending);
118
+ if (completedQueries.length === 0) return null;
119
+ return completedQueries[completedQueries.length - 1].question;
120
+ };
121
+
122
+
123
+ const colCount = selectedDataset.columns ? selectedDataset.columns.length : 0;
124
+
125
+ return (
126
+ <div
127
+ style={{
128
+ display: "flex",
129
+ flexDirection: "column",
130
+ height: "calc(100vh - 120px)",
131
+ margin: "-32px",
132
+ overflow: "hidden"
133
+ }}
134
+ >
135
+ {/* 1. Dataset Context Bar (40px) */}
136
+ <div
137
+ style={{
138
+ height: "40px",
139
+ minHeight: "40px",
140
+ backgroundColor: "var(--surface-color)",
141
+ borderBottom: "1px solid var(--border-color)",
142
+ display: "flex",
143
+ alignItems: "center",
144
+ padding: "0 20px",
145
+ fontSize: "0.8rem",
146
+ color: "var(--text-secondary)",
147
+ fontWeight: "500"
148
+ }}
149
+ >
150
+ <span>Querying:</span>
151
+ <strong style={{ color: "var(--text-primary)", marginLeft: "4px" }}>
152
+ {selectedDataset.display_name}
153
+ </strong>
154
+ <span style={{ margin: "0 8px", color: "var(--border-color)" }}>•</span>
155
+ <span>{selectedDataset.row_count.toLocaleString()} rows</span>
156
+ <span style={{ margin: "0 8px", color: "var(--border-color)" }}>•</span>
157
+ <span>{colCount} columns</span>
158
+ </div>
159
+
160
+ {/* 2. Query History Area (flex-grow, scrollable) */}
161
+ <div
162
+ style={{
163
+ flexGrow: 1,
164
+ overflowY: "auto",
165
+ padding: "24px",
166
+ display: "flex",
167
+ flexDirection: "column",
168
+ gap: "24px",
169
+ backgroundColor: "var(--bg-color)"
170
+ }}
171
+ >
172
+ {queryHistory.length === 0 ? (
173
+ <div
174
+ style={{
175
+ display: "flex",
176
+ flexDirection: "column",
177
+ alignItems: "center",
178
+ justifyContent: "center",
179
+ flexGrow: 1,
180
+ color: "var(--text-muted)",
181
+ fontSize: "0.9rem",
182
+ gap: "8px",
183
+ padding: "40px"
184
+ }}
185
+ >
186
+ <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ opacity: 0.6 }}>
187
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
188
+ </svg>
189
+ <span>Ask a question below to begin your analysis.</span>
190
+ <span style={{ fontSize: "0.8rem", opacity: 0.8 }}>Questions are converted to SQL in real-time.</span>
191
+ <span style={{ fontSize: "0.75rem", color: "var(--text-secondary)", marginTop: "4px" }}>
192
+ Shortcut: press <kbd style={{ fontFamily: "var(--font-mono)", background: "var(--surface-color)", padding: "2px 4px", borderRadius: "3px" }}>Ctrl+K</kbd> to focus input.
193
+ </span>
194
+ </div>
195
+ ) : (
196
+ queryHistory.map((item) => {
197
+ if (item.isPending) {
198
+ return <LoadingResult key={item.id} question={item.question} />;
199
+ }
200
+ return <QueryResult key={item.id} result={item} />;
201
+ })
202
+ )}
203
+
204
+ {/* Global Error Banner */}
205
+ {error && (
206
+ <div
207
+ className="upload-status error"
208
+ style={{
209
+ display: "flex",
210
+ flexDirection: "column",
211
+ gap: "4px",
212
+ borderRadius: "8px"
213
+ }}
214
+ >
215
+ <div style={{ fontWeight: "bold" }}>Query Pipeline Failed</div>
216
+ <div>{error}</div>
217
+ </div>
218
+ )}
219
+
220
+ {/* Scroll Anchor */}
221
+ <div ref={historyEndRef} />
222
+ </div>
223
+
224
+ {/* 3. Query Suggestions Panel (rendered right above QueryInput) */}
225
+ <div
226
+ style={{
227
+ padding: "10px 20px",
228
+ borderTop: "1px solid var(--border-color)",
229
+ backgroundColor: "var(--surface-color)",
230
+ display: "flex",
231
+ alignItems: "center",
232
+ gap: "12px",
233
+ width: "100%",
234
+ zIndex: 10
235
+ }}
236
+ >
237
+ <span style={{ fontSize: "0.75rem", fontWeight: "700", color: "var(--text-secondary)", whiteSpace: "nowrap" }}>
238
+ 💡 Suggested:
239
+ </span>
240
+ <div className="suggestions-scroll-container" style={{ flexGrow: 1 }}>
241
+ {suggestionsLoading ? (
242
+ // Shimmer skeletons
243
+ [1, 2, 3].map((i) => (
244
+ <div
245
+ key={i}
246
+ className="shimmer"
247
+ style={{
248
+ height: "22px",
249
+ width: "140px",
250
+ borderRadius: "100px",
251
+ flexShrink: 0
252
+ }}
253
+ />
254
+ ))
255
+ ) : (
256
+ suggestions.map((sug, idx) => (
257
+ <button
258
+ key={idx}
259
+ type="button"
260
+ className="suggestion-chip"
261
+ onClick={() => handleQuerySubmit(sug)}
262
+ disabled={loading}
263
+ style={{
264
+ borderRadius: "100px",
265
+ border: "1px solid #2c2c2c",
266
+ fontSize: "0.75rem",
267
+ padding: "4px 10px",
268
+ whiteSpace: "nowrap",
269
+ flexShrink: 0
270
+ }}
271
+ >
272
+ {sug}
273
+ </button>
274
+ ))
275
+ )}
276
+ </div>
277
+ </div>
278
+
279
+ {/* 4. Query Input Area (fixed bottom, ~100px) */}
280
+ <QueryInput
281
+ onSubmit={handleQuerySubmit}
282
+ loading={loading}
283
+ datasetName={selectedDataset.db_name}
284
+ schema={schema}
285
+ onArrowUp={getLastQuestion}
286
+ />
287
+ </div>
288
+ );
289
+ }
frontend/src/components/SchemaPreviewPanel.jsx ADDED
@@ -0,0 +1,679 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from "react";
2
+ import { getSchemaInfo, getSchemaPreview } from "../api";
3
+ import DataTable from "./DataTable";
4
+
5
+ const getParticipationExplanation = (rel) => {
6
+ const from = rel.from_table || "";
7
+ const to = rel.to_table || "";
8
+ const fromPart = rel.from_participation || "total";
9
+ const toPart = rel.to_participation || "partial";
10
+
11
+ const singular = (name) => {
12
+ if (name.endsWith("ies")) return name.slice(0, -3) + "y";
13
+ if (name.endsWith("s")) return name.slice(0, -1);
14
+ return name;
15
+ };
16
+
17
+ const cap = (s) => s ? s.charAt(0).toUpperCase() + s.slice(1) : "";
18
+
19
+ const fromExp = fromPart === "total"
20
+ ? `every ${singular(from)} must have a ${singular(to)}`
21
+ : `not all ${from} have ${to}`;
22
+
23
+ const toExp = toPart === "total"
24
+ ? `every ${singular(to)} must have ${from}`
25
+ : `not all ${to} have ${from}`;
26
+
27
+ return {
28
+ fromText: <>{cap(singular(from))} participation: <strong>{fromPart}</strong> ({fromExp})</>,
29
+ toText: <>{cap(singular(to))} participation: <strong>{toPart}</strong> ({toExp})</>
30
+ };
31
+ };
32
+
33
+ export default function SchemaPreviewPanel({ dataset, onStartQuerying, addToast }) {
34
+ const [schemaInfo, setSchemaInfo] = useState(null);
35
+ const [previews, setPreviews] = useState([]);
36
+ const [loading, setLoading] = useState(true);
37
+ const [activeTab, setActiveTab] = useState("");
38
+ const [hoveredRel, setHoveredRel] = useState(null);
39
+ const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
40
+
41
+ useEffect(() => {
42
+ const loadSchemaDetails = async () => {
43
+ setLoading(true);
44
+ try {
45
+ console.log("[SchemaPreviewPanel] Fetching details for:", dataset.db_name);
46
+ const [infoRes, previewRes] = await Promise.all([
47
+ getSchemaInfo(dataset.db_name),
48
+ getSchemaPreview(dataset.db_name)
49
+ ]);
50
+
51
+ setSchemaInfo(infoRes);
52
+ setPreviews(previewRes.previews || []);
53
+
54
+ if (infoRes.tables && infoRes.tables.length > 0) {
55
+ setActiveTab(infoRes.tables[0].name);
56
+ }
57
+ setLoading(false);
58
+ } catch (err) {
59
+ console.error("[SchemaPreviewPanel] Failed to load schema info:", err);
60
+ addToast("Failed to load schema details: " + err.message, "error");
61
+ setLoading(false);
62
+ }
63
+ };
64
+
65
+ if (dataset) {
66
+ loadSchemaDetails();
67
+ }
68
+ }, [dataset]);
69
+
70
+ if (loading) {
71
+ return (
72
+ <div className="loading-container" style={{ margin: "auto" }}>
73
+ <div className="spinner" style={{ borderColor: "var(--border-color)", borderTopColor: "#8b5cf6" }}></div>
74
+ <div style={{ color: "var(--text-secondary)", fontSize: "0.9rem" }}>
75
+ Loading relational schema and ER diagram...
76
+ </div>
77
+ </div>
78
+ );
79
+ }
80
+
81
+ if (!schemaInfo) {
82
+ return (
83
+ <div style={{ color: "var(--error-color)", padding: "24px", textAlign: "center" }}>
84
+ Failed to load schema details.
85
+ </div>
86
+ );
87
+ }
88
+
89
+ // Math positions for ERD SVG
90
+ const numTables = schemaInfo.tables.length;
91
+ const positions = {};
92
+ let svgWidth = 700;
93
+ let svgHeight = 380;
94
+
95
+ // Custom gorgeous layout for the ecommerce sample database
96
+ if (dataset.db_name === "ecommerce") {
97
+ positions["customers"] = { x: 40, y: 30 };
98
+ positions["products"] = { x: 40, y: 230 };
99
+ positions["orders"] = { x: 260, y: 130 };
100
+ positions["order_items"] = { x: 480, y: 30 };
101
+ positions["reviews"] = { x: 480, y: 230 };
102
+ } else {
103
+ // Generic column-wise grid positioning
104
+ const colCount = numTables >= 6 ? 2 : 3;
105
+ const numRows = Math.ceil(numTables / colCount);
106
+
107
+ // Set explicit dimensions instead of generic 700x380
108
+ svgWidth = Math.max(700, 40 + colCount * 220);
109
+ svgHeight = Math.max(380, 30 + numRows * 170);
110
+
111
+ schemaInfo.tables.forEach((tbl, idx) => {
112
+ const col = idx % colCount;
113
+ const row = Math.floor(idx / colCount);
114
+ positions[tbl.name] = {
115
+ x: 40 + col * 220,
116
+ y: 30 + row * 170
117
+ };
118
+ });
119
+ }
120
+
121
+ const getLineCoords = (rel) => {
122
+ const p1 = positions[rel.from_table];
123
+ const p2 = positions[rel.to_table];
124
+ if (!p1 || !p2) return { x1: 0, y1: 0, x2: 0, y2: 0, isSelfLoop: false };
125
+
126
+ const width = 170;
127
+ const height = 110;
128
+
129
+ let x1, y1, x2, y2;
130
+ let isSelfLoop = false;
131
+
132
+ if (rel.from_table === rel.to_table) {
133
+ isSelfLoop = true;
134
+ // Coordinates for self-loop drawing
135
+ x1 = p1.x + width - 20; // Start near the top right
136
+ y1 = p1.y;
137
+ x2 = p1.x + width; // End near the middle right
138
+ y2 = p1.y + 40;
139
+ return { x1, y1, x2, y2, isSelfLoop, width, height, p1 };
140
+ }
141
+
142
+ // Check relative alignment
143
+ if (p1.x + width < p2.x) {
144
+ // p1 is left of p2
145
+ x1 = p1.x + width;
146
+ y1 = p1.y + height / 2;
147
+ x2 = p2.x;
148
+ y2 = p2.y + height / 2;
149
+ } else if (p2.x + width < p1.x) {
150
+ // p1 is right of p2
151
+ x1 = p1.x;
152
+ y1 = p1.y + height / 2;
153
+ x2 = p2.x + width;
154
+ y2 = p2.y + height / 2;
155
+ } else {
156
+ // Vertically aligned
157
+ x1 = p1.x + width / 2;
158
+ x2 = p2.x + width / 2;
159
+ if (p1.y < p2.y) {
160
+ y1 = p1.y + height;
161
+ y2 = p2.y;
162
+ } else {
163
+ y1 = p1.y;
164
+ y2 = p2.y + height;
165
+ }
166
+ }
167
+
168
+ return { x1, y1, x2, y2, isSelfLoop: false };
169
+ };
170
+
171
+ const activePreview = previews.find(p => p.table_name === activeTab);
172
+
173
+ return (
174
+ <div style={{ display: "flex", flexDirection: "column", gap: "24px" }}>
175
+ {/* Header Info Banner */}
176
+ <div
177
+ style={{
178
+ display: "flex",
179
+ justifyContent: "space-between",
180
+ alignItems: "center",
181
+ borderBottom: "1px solid var(--border-color)",
182
+ paddingBottom: "16px"
183
+ }}
184
+ >
185
+ <div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
186
+ <h2 style={{ fontSize: "1.5rem", fontWeight: 700 }}>
187
+ {schemaInfo.db_name === "ecommerce" ? "E-Commerce Database" : schemaInfo.db_name.replace("_", " ").toUpperCase()}
188
+ </h2>
189
+ <div style={{ display: "flex", gap: "12px", fontSize: "0.8rem", color: "var(--text-secondary)" }}>
190
+ <span>📂 <strong>{schemaInfo.total_tables}</strong> tables</span>
191
+ <span>•</span>
192
+ <span>🔗 <strong>{schemaInfo.relationships.length}</strong> relationships</span>
193
+ <span>•</span>
194
+ <span>📊 <strong>{schemaInfo.total_rows.toLocaleString()}</strong> total rows</span>
195
+ </div>
196
+ </div>
197
+ <button
198
+ className="btn-primary"
199
+ onClick={onStartQuerying}
200
+ style={{ backgroundColor: "#8b5cf6" }}
201
+ >
202
+ Start Asking Questions &rarr;
203
+ </button>
204
+ </div>
205
+
206
+ {/* SVG ERD Section */}
207
+ <div
208
+ style={{
209
+ backgroundColor: "var(--surface-color)",
210
+ border: "1px solid var(--border-color)",
211
+ borderRadius: "8px",
212
+ padding: "16px",
213
+ position: "relative"
214
+ }}
215
+ >
216
+ <div style={{ fontSize: "0.85rem", fontWeight: 700, textTransform: "uppercase", color: "#a78bfa", marginBottom: "12px", display: "flex", justifyContent: "space-between", alignItems: "center" }}>
217
+ <span>Entity Relationship Diagram (ERD)</span>
218
+ <div style={{ display: "flex", gap: "12px", alignItems: "center" }}>
219
+ {hoveredRel && (
220
+ <span style={{ fontSize: "0.75rem", textTransform: "none", color: "var(--text-primary)", backgroundColor: "#2d1b69", padding: "2px 8px", borderRadius: "4px" }}>
221
+ Relationship: <code>{hoveredRel.from_table}.{hoveredRel.from_column}</code> &rarr; <code>{hoveredRel.to_table}.{hoveredRel.to_column}</code>
222
+ </span>
223
+ )}
224
+ <button
225
+ onClick={() => {
226
+ const el = document.getElementById("erd-scroll-area");
227
+ if (el) { el.scrollTop = 0; el.scrollLeft = 0; }
228
+ }}
229
+ style={{
230
+ backgroundColor: "#2d1b69",
231
+ border: "1px solid #8b5cf6",
232
+ color: "#f9fafb",
233
+ padding: "4px 8px",
234
+ borderRadius: "4px",
235
+ fontSize: "0.75rem",
236
+ cursor: "pointer",
237
+ textTransform: "none"
238
+ }}
239
+ >
240
+ ↺ Reset View
241
+ </button>
242
+ </div>
243
+ </div>
244
+
245
+ <style>{`
246
+ .erd-scroll-container::-webkit-scrollbar { width: 8px; height: 8px; }
247
+ .erd-scroll-container::-webkit-scrollbar-thumb { background: #8b5cf6; border-radius: 4px; }
248
+ .erd-scroll-container::-webkit-scrollbar-track { background: #1a1a1a; border-radius: 4px; }
249
+ `}</style>
250
+
251
+ <div id="erd-scroll-area" className="erd-scroll-container" style={{ width: "100%", maxHeight: "60vh", overflow: "auto", border: "1px solid var(--border-color)", borderRadius: "6px", backgroundColor: "#0f0f0f" }}>
252
+ <svg
253
+ width={svgWidth}
254
+ height={svgHeight}
255
+ style={{ minWidth: "650px", display: "block" }}
256
+ >
257
+ {/* Markers Definitions for Arrows */}
258
+ <defs>
259
+ <marker
260
+ id="arrow"
261
+ viewBox="0 0 10 10"
262
+ refX="8" refY="5"
263
+ markerWidth="5" markerHeight="5"
264
+ orient="auto-start-reverse"
265
+ >
266
+ <path d="M 0 0 L 10 5 L 0 10 z" fill="#8b5cf6" />
267
+ </marker>
268
+ </defs>
269
+
270
+ {/* Relationship Lines */}
271
+ {schemaInfo.relationships.map((rel, idx) => {
272
+ const coords = getLineCoords(rel);
273
+ if (coords.x1 === 0 && coords.y1 === 0 && coords.x2 === 0 && coords.y2 === 0) return null;
274
+
275
+ const isHovered = hoveredRel &&
276
+ hoveredRel.from_table === rel.from_table &&
277
+ hoveredRel.from_column === rel.from_column &&
278
+ hoveredRel.to_table === rel.to_table &&
279
+ hoveredRel.to_column === rel.to_column;
280
+
281
+ const dx = coords.x2 - coords.x1;
282
+ const dy = coords.y2 - coords.y1;
283
+ const len = Math.sqrt(dx * dx + dy * dy);
284
+ const ux = len > 0 ? dx / len : 0;
285
+ const uy = len > 0 ? dy / len : 0;
286
+ const px = -uy;
287
+ const py = ux;
288
+
289
+ const xMid = (coords.x1 + coords.x2) / 2;
290
+ const yMid = (coords.y1 + coords.y2) / 2;
291
+
292
+ const cardinality = rel.cardinality || "1:N";
293
+ const fromPart = rel.from_participation || "total";
294
+ const toPart = rel.to_participation || "partial";
295
+
296
+ return (
297
+ <g
298
+ key={idx}
299
+ onMouseEnter={() => setHoveredRel(rel)}
300
+ onMouseMove={(e) => setMousePos({ x: e.clientX, y: e.clientY })}
301
+ onMouseLeave={() => setHoveredRel(null)}
302
+ style={{ cursor: "pointer" }}
303
+ >
304
+ {coords.isSelfLoop ? (
305
+ <>
306
+ {/* Thick transparent interactive buffer path for easier hovering */}
307
+ <path
308
+ d={`M ${coords.x1} ${coords.y1} C ${coords.x1 + 80} ${coords.y1 - 30}, ${coords.x2 + 80} ${coords.y2 + 30}, ${coords.x2} ${coords.y2}`}
309
+ fill="none"
310
+ stroke="transparent"
311
+ strokeWidth={15}
312
+ />
313
+ <path
314
+ d={`M ${coords.x1} ${coords.y1} C ${coords.x1 + 80} ${coords.y1 - 30}, ${coords.x2 + 80} ${coords.y2 + 30}, ${coords.x2} ${coords.y2}`}
315
+ fill="none"
316
+ stroke={isHovered ? "#a78bfa" : "#8b5cf6"}
317
+ strokeWidth={isHovered ? 2.5 : 1.5}
318
+ strokeDasharray={fromPart === "partial" || toPart === "partial" ? "4, 4" : "none"}
319
+ />
320
+ {/* Arrow at the end of the loop to indicate direction */}
321
+ <polygon
322
+ points={`${coords.x2},${coords.y2} ${coords.x2 + 10},${coords.y2 - 5} ${coords.x2 + 8},${coords.y2} ${coords.x2 + 10},${coords.y2 + 5}`}
323
+ fill={isHovered ? "#a78bfa" : "#8b5cf6"}
324
+ />
325
+ </>
326
+ ) : (
327
+ <>
328
+ {/* Thick transparent interactive buffer line for easier hovering */}
329
+ <line
330
+ x1={coords.x1} y1={coords.y1}
331
+ x2={coords.x2} y2={coords.y2}
332
+ stroke="transparent"
333
+ strokeWidth={10}
334
+ />
335
+
336
+ {/* Segment 1: from_table end to midpoint */}
337
+ {fromPart === "total" ? (
338
+ <>
339
+ <line
340
+ x1={coords.x1 - px * 1.5} y1={coords.y1 - py * 1.5}
341
+ x2={xMid - px * 1.5} y2={yMid - py * 1.5}
342
+ stroke={isHovered ? "#a78bfa" : "#8b5cf6"}
343
+ strokeWidth={isHovered ? 1.8 : 1.0}
344
+ />
345
+ <line
346
+ x1={coords.x1 + px * 1.5} y1={coords.y1 + py * 1.5}
347
+ x2={xMid + px * 1.5} y2={yMid + py * 1.5}
348
+ stroke={isHovered ? "#a78bfa" : "#8b5cf6"}
349
+ strokeWidth={isHovered ? 1.8 : 1.0}
350
+ />
351
+ </>
352
+ ) : (
353
+ <line
354
+ x1={coords.x1} y1={coords.y1}
355
+ x2={xMid} y2={yMid}
356
+ stroke={isHovered ? "#a78bfa" : "#8b5cf6"}
357
+ strokeWidth={isHovered ? 2.5 : 1.5}
358
+ strokeDasharray="4, 4"
359
+ />
360
+ )}
361
+
362
+ {/* Segment 2: midpoint to to_table end */}
363
+ {toPart === "total" ? (
364
+ <>
365
+ <line
366
+ x1={xMid - px * 1.5} y1={yMid - py * 1.5}
367
+ x2={coords.x2 - px * 1.5} y2={coords.y2 - py * 1.5}
368
+ stroke={isHovered ? "#a78bfa" : "#8b5cf6"}
369
+ strokeWidth={isHovered ? 1.8 : 1.0}
370
+ />
371
+ <line
372
+ x1={xMid + px * 1.5} y1={yMid + py * 1.5}
373
+ x2={coords.x2 + px * 1.5} y2={coords.y2 + py * 1.5}
374
+ stroke={isHovered ? "#a78bfa" : "#8b5cf6"}
375
+ strokeWidth={isHovered ? 1.8 : 1.0}
376
+ />
377
+ </>
378
+ ) : (
379
+ <line
380
+ x1={xMid} y1={yMid}
381
+ x2={coords.x2} y2={coords.y2}
382
+ stroke={isHovered ? "#a78bfa" : "#8b5cf6"}
383
+ strokeWidth={isHovered ? 2.5 : 1.5}
384
+ strokeDasharray="4, 4"
385
+ />
386
+ )}
387
+
388
+ {/* Crow's Foot / Tick at from_table end (x1, y1) */}
389
+ {cardinality === "1:N" || cardinality === "N:M" ? (
390
+ /* Crow's Foot */
391
+ <>
392
+ <line
393
+ x1={coords.x1 - uy * 5} y1={coords.y1 + ux * 5}
394
+ x2={coords.x1 + ux * 10} y2={coords.y1 + uy * 10}
395
+ stroke="#8b5cf6"
396
+ strokeWidth={1.5}
397
+ />
398
+ <line
399
+ x1={coords.x1 + uy * 5} y1={coords.y1 - ux * 5}
400
+ x2={coords.x1 + ux * 10} y2={coords.y1 + uy * 10}
401
+ stroke="#8b5cf6"
402
+ strokeWidth={1.5}
403
+ />
404
+ </>
405
+ ) : (
406
+ /* Single Tick */
407
+ <line
408
+ x1={coords.x1 + ux * 6 - uy * 5} y1={coords.y1 + uy * 6 + ux * 5}
409
+ x2={coords.x1 + ux * 6 + uy * 5} y2={coords.y1 + uy * 6 - ux * 5}
410
+ stroke="#8b5cf6"
411
+ strokeWidth={1.5}
412
+ />
413
+ )}
414
+
415
+ {/* Crow's Foot / Tick at to_table end (x2, y2) */}
416
+ {cardinality === "N:M" ? (
417
+ /* Crow's Foot */
418
+ <>
419
+ <line
420
+ x1={coords.x2 + uy * 5} y1={coords.y2 - ux * 5}
421
+ x2={coords.x2 - ux * 10} y2={coords.y2 - uy * 10}
422
+ stroke="#8b5cf6"
423
+ strokeWidth={1.5}
424
+ />
425
+ <line
426
+ x1={coords.x2 - uy * 5} y1={coords.y2 + ux * 5}
427
+ x2={coords.x2 - ux * 10} y2={coords.y2 - uy * 10}
428
+ stroke="#8b5cf6"
429
+ strokeWidth={1.5}
430
+ />
431
+ </>
432
+ ) : (
433
+ /* Single Tick */
434
+ <line
435
+ x1={coords.x2 - ux * 6 - uy * 5} y1={coords.y2 - uy * 6 + ux * 5}
436
+ x2={coords.x2 - ux * 6 + uy * 5} y2={coords.y2 - uy * 6 - ux * 5}
437
+ stroke="#8b5cf6"
438
+ strokeWidth={1.5}
439
+ />
440
+ )}
441
+ </>
442
+ )}
443
+
444
+ {/* Midpoint label overlay */}
445
+ {rel.relationship_name && (
446
+ <g transform={`translate(${xMid}, ${yMid})`} pointerEvents="none">
447
+ <rect
448
+ x={-(rel.relationship_name.length * 5 + 8) / 2}
449
+ y={-9}
450
+ width={rel.relationship_name.length * 5 + 8}
451
+ height={18}
452
+ rx={6}
453
+ fill="var(--surface-elevated)"
454
+ stroke="var(--border-color)"
455
+ strokeWidth={1}
456
+ />
457
+ <text
458
+ textAnchor="middle"
459
+ y={3}
460
+ fontSize={10}
461
+ fontStyle="italic"
462
+ fill="#9ca3af"
463
+ fontFamily="var(--font-sans)"
464
+ >
465
+ {rel.relationship_name}
466
+ </text>
467
+ </g>
468
+ )}
469
+ </g>
470
+ );
471
+ })}
472
+
473
+ {/* Table Boxes */}
474
+ {schemaInfo.tables.map((tbl) => {
475
+ const pos = positions[tbl.name] || { x: 50, y: 50 };
476
+ const width = 170;
477
+ const height = 110;
478
+
479
+ return (
480
+ <g key={tbl.name} transform={`translate(${pos.x}, ${pos.y})`}>
481
+ {/* Outer rect */}
482
+ <rect
483
+ width={width}
484
+ height={height}
485
+ rx={8}
486
+ fill="var(--surface-elevated)"
487
+ stroke="#8b5cf6"
488
+ strokeWidth={1.2}
489
+ filter="drop-shadow(0px 2px 4px rgba(0,0,0,0.4))"
490
+ />
491
+
492
+ {/* Header rect */}
493
+ <rect
494
+ width={width}
495
+ height={26}
496
+ rx={8}
497
+ fill="#2d1b69"
498
+ clipPath="inset(0px 0px 84px 0px)"
499
+ />
500
+
501
+ {/* Header divider */}
502
+ <line x1={0} y1={26} x2={width} y2={26} stroke="#8b5cf6" strokeWidth={1} />
503
+
504
+ {/* Header text */}
505
+ <text
506
+ x={width / 2} y={17}
507
+ fill="#f9fafb"
508
+ fontSize={11}
509
+ fontWeight="bold"
510
+ textAnchor="middle"
511
+ fontFamily="var(--font-sans)"
512
+ >
513
+ {tbl.name} ({tbl.row_count})
514
+ </text>
515
+
516
+ {/* Columns list */}
517
+ {tbl.columns.slice(0, 5).map((col, cIdx) => {
518
+ const isPK = col.is_primary_key;
519
+ const isFK = col.is_foreign_key;
520
+
521
+ return (
522
+ <g key={col.name} transform={`translate(10, ${42 + cIdx * 13})`}>
523
+ {/* Key icon / label prefix */}
524
+ <text
525
+ x={0} y={0}
526
+ fontSize={9}
527
+ fill={isPK ? "#f59e0b" : (isFK ? "#8b5cf6" : "transparent")}
528
+ fontFamily="var(--font-sans)"
529
+ >
530
+ {isPK ? "🔑" : (isFK ? "🔗" : "")}
531
+ </text>
532
+
533
+ {/* Column Name */}
534
+ <text
535
+ x={12} y={0}
536
+ fontSize={9.5}
537
+ fill={isPK ? "#f9fafb" : (isFK ? "#a78bfa" : "var(--text-secondary)")}
538
+ fontWeight={isPK || isFK ? "600" : "400"}
539
+ fontFamily="var(--font-mono)"
540
+ >
541
+ {col.name.length > 18 ? col.name.slice(0, 16) + ".." : col.name}
542
+ </text>
543
+ </g>
544
+ );
545
+ })}
546
+
547
+ {/* Overflow indicator if table has > 5 columns */}
548
+ {tbl.columns.length > 5 && (
549
+ <text
550
+ x={width / 2} y={105}
551
+ fill="var(--text-muted)"
552
+ fontSize={8}
553
+ textAnchor="middle"
554
+ fontStyle="italic"
555
+ fontFamily="var(--font-sans)"
556
+ >
557
+ + {tbl.columns.length - 5} more columns
558
+ </text>
559
+ )}
560
+ </g>
561
+ );
562
+ })}
563
+ </svg>
564
+ </div>
565
+
566
+ {/* ERD Legend Box */}
567
+ <div
568
+ style={{
569
+ marginTop: "16px",
570
+ backgroundColor: "#1a1025",
571
+ border: "1px solid #2c2c2c",
572
+ borderRadius: "6px",
573
+ padding: "10px 14px",
574
+ fontSize: "11px",
575
+ color: "#f9fafb",
576
+ fontFamily: "var(--font-sans)",
577
+ display: "inline-block"
578
+ }}
579
+ >
580
+ <div style={{ fontWeight: "bold", borderBottom: "1px solid #2c2c2c", paddingBottom: "4px", marginBottom: "6px", color: "#a78bfa" }}>
581
+ ERD Legend
582
+ </div>
583
+ <div style={{ display: "grid", gridTemplateColumns: "auto 1fr auto 1fr", gap: "4px 16px", alignItems: "center" }}>
584
+ <span style={{ color: "#8b5cf6", fontWeight: "600" }}>── single tick</span>
585
+ <span>= One (1)</span>
586
+
587
+ <span style={{ color: "#8b5cf6", fontWeight: "600" }}>══ double line</span>
588
+ <span>= Total participation (mandatory)</span>
589
+
590
+ <span style={{ color: "#8b5cf6", fontWeight: "600" }}>──&lt; crow's foot</span>
591
+ <span>= Many (N)</span>
592
+
593
+ <span style={{ color: "#8b5cf6", fontWeight: "600" }}>╌╌ dashed line</span>
594
+ <span>= Partial participation (optional)</span>
595
+ </div>
596
+ </div>
597
+
598
+ </div>
599
+
600
+ {/* Tabs and Data Preview Section */}
601
+ <div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
602
+ <h3 className="section-title" style={{ marginTop: 0 }}>Table Data Previews</h3>
603
+
604
+ {/* Tab Selection Row */}
605
+ <div style={{ display: "flex", gap: "8px", overflowX: "auto", borderBottom: "1px solid var(--border-color)", paddingBottom: "6px" }}>
606
+ {schemaInfo.tables.map((tbl) => (
607
+ <button
608
+ key={tbl.name}
609
+ onClick={() => setActiveTab(tbl.name)}
610
+ className={`view-tab ${activeTab === tbl.name ? "active" : ""}`}
611
+ style={{
612
+ borderRadius: "4px",
613
+ borderBottom: activeTab === tbl.name ? "2px solid #8b5cf6" : "none",
614
+ color: activeTab === tbl.name ? "#a78bfa" : "var(--text-secondary)"
615
+ }}
616
+ >
617
+ {tbl.name} ({tbl.row_count})
618
+ </button>
619
+ ))}
620
+ </div>
621
+
622
+ {/* Preview Data Grid */}
623
+ {activePreview ? (
624
+ <div>
625
+ <DataTable
626
+ columns={activePreview.columns}
627
+ rows={activePreview.rows}
628
+ maxHeight="240px"
629
+ />
630
+ <div style={{ fontSize: "0.75rem", color: "var(--text-muted)", marginTop: "8px", textAlign: "right" }}>
631
+ Showing first 5 rows of {activePreview.total_rows} total rows
632
+ </div>
633
+ </div>
634
+ ) : (
635
+ <div style={{ color: "var(--text-secondary)", fontSize: "0.85rem", padding: "20px", textAlign: "center" }}>
636
+ No preview rows loaded.
637
+ </div>
638
+ )}
639
+ </div>
640
+
641
+ {/* Floating Tooltip */}
642
+ {hoveredRel && (() => {
643
+ const expl = getParticipationExplanation(hoveredRel);
644
+ return (
645
+ <div
646
+ style={{
647
+ position: "fixed",
648
+ left: mousePos.x + 12,
649
+ top: mousePos.y + 12,
650
+ zIndex: 2000,
651
+ backgroundColor: "var(--surface-elevated)",
652
+ border: "1px solid var(--border-color)",
653
+ borderRadius: "8px",
654
+ padding: "12px",
655
+ boxShadow: "0 8px 24px rgba(0, 0, 0, 0.6)",
656
+ fontSize: "0.8rem",
657
+ color: "var(--text-primary)",
658
+ pointerEvents: "none",
659
+ maxWidth: "320px",
660
+ fontFamily: "var(--font-sans)",
661
+ lineHeight: "1.4"
662
+ }}
663
+ >
664
+ <div style={{ fontWeight: 700, borderBottom: "1px solid var(--border-color)", paddingBottom: "6px", marginBottom: "6px", color: "#a78bfa" }}>
665
+ <code>{hoveredRel.from_table}</code> &rarr; <code>{hoveredRel.to_table}</code>
666
+ </div>
667
+ <div style={{ marginBottom: "6px" }}>
668
+ <strong>Cardinality:</strong> <span style={{ color: "#a78bfa", fontWeight: 600 }}>{hoveredRel.cardinality || "1:N"}</span>
669
+ </div>
670
+ <div style={{ display: "flex", flexDirection: "column", gap: "4px", color: "var(--text-secondary)" }}>
671
+ <div>• {expl.fromText}</div>
672
+ <div>• {expl.toText}</div>
673
+ </div>
674
+ </div>
675
+ );
676
+ })()}
677
+ </div>
678
+ );
679
+ }
frontend/src/components/SchemaQueryView.jsx ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useRef } from "react";
2
+ import QueryInput from "./QueryInput";
3
+ import QueryResult from "./QueryResult";
4
+ import LoadingResult from "./LoadingResult";
5
+ import { querySchema } from "../api";
6
+
7
+ export default function SchemaQueryView({ selectedDataset, schemaInfo, onQuerySuccess, activeQuestion, addToast }) {
8
+ const [queryHistory, setQueryHistory] = useState([]);
9
+ const [loading, setLoading] = useState(false);
10
+ const [error, setError] = useState(null);
11
+
12
+ const historyEndRef = useRef(null);
13
+
14
+ // Suggested questions in JOIN-heavy schema mode
15
+ const suggestions = [
16
+ "Which customers spent the most total?",
17
+ "Show top 5 products by revenue",
18
+ "What is the average order value by payment method?",
19
+ "Which products have the most reviews?",
20
+ "Show monthly order counts and revenue"
21
+ ];
22
+
23
+ // 1. Clear history when selected dataset changes
24
+ useEffect(() => {
25
+ setQueryHistory([]);
26
+ setError(null);
27
+ setLoading(false);
28
+ }, [selectedDataset]);
29
+
30
+ // 2. Scroll to bottom when history grows
31
+ useEffect(() => {
32
+ if (historyEndRef.current) {
33
+ historyEndRef.current.scrollIntoView({ behavior: "smooth" });
34
+ }
35
+ }, [queryHistory]);
36
+
37
+ // 3. Register global keyboard shortcut Ctrl+K to focus query input from anywhere
38
+ useEffect(() => {
39
+ const handleGlobalKeyDown = (e) => {
40
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
41
+ e.preventDefault();
42
+ const inputEl = document.querySelector(".query-input");
43
+ if (inputEl) {
44
+ inputEl.focus();
45
+ inputEl.select(); // Highlight any existing text inside
46
+ }
47
+ }
48
+ };
49
+ window.addEventListener("keydown", handleGlobalKeyDown);
50
+ return () => window.removeEventListener("keydown", handleGlobalKeyDown);
51
+ }, []);
52
+
53
+ // 4. Trigger query from external prop (like sidebar recent queries)
54
+ useEffect(() => {
55
+ if (activeQuestion && activeQuestion.text && activeQuestion.timestamp) {
56
+ handleQuerySubmit(activeQuestion.text);
57
+ }
58
+ }, [activeQuestion]);
59
+
60
+ const handleQuerySubmit = async (questionText) => {
61
+ setError(null);
62
+ setLoading(true);
63
+
64
+ const pendingId = "pending_" + Date.now();
65
+ const pendingEntry = {
66
+ id: pendingId,
67
+ question: questionText,
68
+ timestamp: new Date(),
69
+ isPending: true
70
+ };
71
+
72
+ setQueryHistory((prev) => [...prev, pendingEntry]);
73
+
74
+ try {
75
+ console.log(`[SchemaQueryView] Executing query: '${questionText}' on '${selectedDataset.db_name}'`);
76
+ const res = await querySchema(questionText, selectedDataset.db_name);
77
+
78
+ // Update entry with real execution results
79
+ setQueryHistory((prev) =>
80
+ prev.map((item) =>
81
+ item.id === pendingId
82
+ ? { ...res, id: pendingId, timestamp: pendingEntry.timestamp }
83
+ : item
84
+ )
85
+ );
86
+ setLoading(false);
87
+ addToast("Relational query completed", "success");
88
+ if (onQuerySuccess) {
89
+ onQuerySuccess();
90
+ }
91
+ } catch (err) {
92
+ console.error("[SchemaQueryView] Relational query pipeline error:", err);
93
+ setError(err.message || "Failed to execute query.");
94
+ setLoading(false);
95
+ addToast("Query failed: " + err.message, "error");
96
+ // Remove loading block
97
+ setQueryHistory((prev) => prev.filter((item) => item.id !== pendingId));
98
+ }
99
+ };
100
+
101
+ const getLastQuestion = () => {
102
+ if (queryHistory.length === 0) return null;
103
+ const completedQueries = queryHistory.filter(h => !h.isPending);
104
+ if (completedQueries.length === 0) return null;
105
+ return completedQueries[completedQueries.length - 1].question;
106
+ };
107
+
108
+ const tablesCount = selectedDataset.total_tables || (selectedDataset.tables ? selectedDataset.tables.length : 0);
109
+ const relsCount = selectedDataset.relationships ? selectedDataset.relationships.length : 0;
110
+
111
+ return (
112
+ <div
113
+ style={{
114
+ display: "flex",
115
+ flexDirection: "column",
116
+ height: "calc(100vh - 120px)",
117
+ margin: "-32px",
118
+ overflow: "hidden"
119
+ }}
120
+ >
121
+ {/* 1. Context Bar (40px) */}
122
+ <div
123
+ style={{
124
+ height: "40px",
125
+ minHeight: "40px",
126
+ backgroundColor: "var(--surface-color)",
127
+ borderBottom: "1px solid var(--border-color)",
128
+ display: "flex",
129
+ alignItems: "center",
130
+ padding: "0 20px",
131
+ fontSize: "0.8rem",
132
+ color: "var(--text-secondary)",
133
+ fontWeight: "500"
134
+ }}
135
+ >
136
+ <span>Relational Query:</span>
137
+ <strong style={{ color: "#a78bfa", marginLeft: "4px" }}>
138
+ {selectedDataset.display_name}
139
+ </strong>
140
+ <span style={{ margin: "0 8px", color: "var(--border-color)" }}>•</span>
141
+ <span>{tablesCount} tables</span>
142
+ <span style={{ margin: "0 8px", color: "var(--border-color)" }}>•</span>
143
+ <span>{relsCount} relationships</span>
144
+ </div>
145
+
146
+ {/* 2. Query History Area */}
147
+ <div
148
+ style={{
149
+ flexGrow: 1,
150
+ overflowY: "auto",
151
+ padding: "24px",
152
+ display: "flex",
153
+ flexDirection: "column",
154
+ gap: "24px",
155
+ backgroundColor: "var(--bg-color)"
156
+ }}
157
+ >
158
+ {queryHistory.length === 0 ? (
159
+ <div
160
+ style={{
161
+ display: "flex",
162
+ flexDirection: "column",
163
+ alignItems: "center",
164
+ justifyContent: "center",
165
+ flexGrow: 1,
166
+ color: "var(--text-muted)",
167
+ fontSize: "0.9rem",
168
+ gap: "8px",
169
+ padding: "40px"
170
+ }}
171
+ >
172
+ <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ opacity: 0.6 }}>
173
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
174
+ </svg>
175
+ <span>Ask a relational question below to query multiple tables using JOINs.</span>
176
+ <span style={{ fontSize: "0.8rem", opacity: 0.8 }}>Tables are automatically joined via foreign key paths.</span>
177
+ <span style={{ fontSize: "0.75rem", color: "var(--text-secondary)", marginTop: "4px" }}>
178
+ Shortcut: press <kbd style={{ fontFamily: "var(--font-mono)", background: "var(--surface-color)", padding: "2px 4px", borderRadius: "3px" }}>Ctrl+K</kbd> to focus input.
179
+ </span>
180
+ </div>
181
+ ) : (
182
+ queryHistory.map((item) => {
183
+ if (item.isPending) {
184
+ return <LoadingResult key={item.id} question={item.question} themeColor="#8b5cf6" />;
185
+ }
186
+ return <QueryResult key={item.id} result={item} />;
187
+ })
188
+ )}
189
+
190
+ {/* Global Error Banner */}
191
+ {error && (
192
+ <div
193
+ className="upload-status error"
194
+ style={{
195
+ display: "flex",
196
+ flexDirection: "column",
197
+ gap: "4px",
198
+ borderRadius: "8px"
199
+ }}
200
+ >
201
+ <div style={{ fontWeight: "bold" }}>Relational Query Pipeline Failed</div>
202
+ <div>{error}</div>
203
+ </div>
204
+ )}
205
+
206
+ {/* Scroll Anchor */}
207
+ <div ref={historyEndRef} />
208
+ </div>
209
+
210
+ {/* 3. Query Suggestions Panel */}
211
+ <div
212
+ style={{
213
+ padding: "10px 20px",
214
+ borderTop: "1px solid var(--border-color)",
215
+ backgroundColor: "var(--surface-color)",
216
+ display: "flex",
217
+ alignItems: "center",
218
+ gap: "12px",
219
+ width: "100%",
220
+ zIndex: 10
221
+ }}
222
+ >
223
+ <span style={{ fontSize: "0.75rem", fontWeight: "700", color: "#a78bfa", whiteSpace: "nowrap" }}>
224
+ 💡 Suggested:
225
+ </span>
226
+ <div className="suggestions-scroll-container" style={{ flexGrow: 1 }}>
227
+ {suggestions.map((sug, idx) => (
228
+ <button
229
+ key={idx}
230
+ type="button"
231
+ className="suggestion-chip"
232
+ onClick={() => handleQuerySubmit(sug)}
233
+ disabled={loading}
234
+ style={{
235
+ borderRadius: "100px",
236
+ border: "1px solid #8b5cf6",
237
+ fontSize: "0.75rem",
238
+ padding: "4px 10px",
239
+ whiteSpace: "nowrap",
240
+ flexShrink: 0
241
+ }}
242
+ onMouseEnter={(e) => e.target.style.borderColor = "#a78bfa"}
243
+ onMouseLeave={(e) => e.target.style.borderColor = "#8b5cf6"}
244
+ >
245
+ {sug}
246
+ </button>
247
+ ))}
248
+ </div>
249
+ </div>
250
+
251
+ {/* 4. Query Input Area */}
252
+ <QueryInput
253
+ onSubmit={handleQuerySubmit}
254
+ loading={loading}
255
+ datasetName={selectedDataset.db_name}
256
+ schema={schemaInfo}
257
+ onArrowUp={getLastQuestion}
258
+ />
259
+ </div>
260
+ );
261
+ }
frontend/src/components/SchemaUploadModal.jsx ADDED
@@ -0,0 +1,422 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useRef } from "react";
2
+ import { uploadSchemaDB } from "../api";
3
+
4
+ export default function SchemaUploadModal({ onClose, onUploadSuccess, addToast }) {
5
+ const [sqlFile, setSqlFile] = useState(null);
6
+ const [dbFile, setDbFile] = useState(null);
7
+ const [erdImage, setErdImage] = useState(null);
8
+ const [erdPreview, setErdPreview] = useState(null);
9
+ const [isUploading, setIsUploading] = useState(false);
10
+ const [error, setError] = useState(null);
11
+ const [success, setSuccess] = useState(false);
12
+
13
+ // Parse details
14
+ const [parsedTablesCount, setParsedTablesCount] = useState(0);
15
+ const [parsedKeysCount, setParsedKeysCount] = useState(0);
16
+
17
+ const sqlInputRef = useRef(null);
18
+ const dbInputRef = useRef(null);
19
+ const erdInputRef = useRef(null);
20
+
21
+ const handleSqlFileChange = (e) => {
22
+ const file = e.target.files[0];
23
+ if (!file) return;
24
+ if (!file.name.endsWith(".sql")) {
25
+ addToast("Schema file must be a .sql file", "error");
26
+ return;
27
+ }
28
+ setSqlFile(file);
29
+
30
+ // Read and parse sql file with client-side regex
31
+ const reader = new FileReader();
32
+ reader.onload = (evt) => {
33
+ const content = evt.target.result;
34
+ // Simple table match
35
+ const tablesMatch = content.match(/CREATE\s+TABLE\s+\w+/gi) || [];
36
+ // Simple foreign key match
37
+ const keysMatch = content.match(/FOREIGN\s+KEY/gi) || [];
38
+ setParsedTablesCount(tablesMatch.length);
39
+ setParsedKeysCount(keysMatch.length);
40
+ };
41
+ reader.readAsText(file);
42
+ };
43
+
44
+ const handleErdFileChange = (e) => {
45
+ const file = e.target.files[0];
46
+ if (!file) return;
47
+ const ext = file.name.substring(file.name.lastIndexOf(".")).toLowerCase();
48
+ if (![".png", ".jpg", ".jpeg", ".pdf"].includes(ext)) {
49
+ addToast("ERD image must be a .png, .jpg, .jpeg, or .pdf file.", "error");
50
+ return;
51
+ }
52
+ setErdImage(file);
53
+ if (ext !== ".pdf") {
54
+ setErdPreview(URL.createObjectURL(file));
55
+ } else {
56
+ setErdPreview(null);
57
+ }
58
+ };
59
+
60
+ const handleDbFileChange = (e) => {
61
+ const file = e.target.files[0];
62
+ if (!file) return;
63
+ if (!file.name.endsWith(".db") && !file.name.endsWith(".sqlite")) {
64
+ addToast("Database file must be a .db or .sqlite file", "error");
65
+ return;
66
+ }
67
+ setDbFile(file);
68
+ };
69
+
70
+ const handleUploadSubmit = async (e) => {
71
+ e.preventDefault();
72
+ if (!dbFile) {
73
+ setError("Database file (.db) is required.");
74
+ addToast("Database file (.db) is required.", "error");
75
+ return;
76
+ }
77
+ if (!sqlFile && !erdImage) {
78
+ setError("Please provide either a .sql schema file or an ERD diagram image");
79
+ addToast("Please provide either a .sql schema file or an ERD diagram image", "error");
80
+ return;
81
+ }
82
+
83
+ setIsUploading(true);
84
+ setError(null);
85
+ setSuccess(false);
86
+
87
+ try {
88
+ console.log("[SchemaUploadModal] Submitting files to server...");
89
+ const result = await uploadSchemaDB(sqlFile, erdImage);
90
+ setSuccess(true);
91
+ setIsUploading(false);
92
+ addToast("Relational database uploaded successfully!", "success");
93
+
94
+ // Auto-dismiss after 2 seconds
95
+ setTimeout(() => {
96
+ if (onUploadSuccess) {
97
+ onUploadSuccess(result);
98
+ }
99
+ onClose();
100
+ }, 2000);
101
+ } catch (err) {
102
+ console.error("[SchemaUploadModal] Upload failed:", err);
103
+ setError(err.message || "Failed to upload files.");
104
+ setIsUploading(false);
105
+ addToast("Upload failed: " + err.message, "error");
106
+ }
107
+ };
108
+
109
+ return (
110
+ <div
111
+ style={{
112
+ position: "fixed",
113
+ top: 0, left: 0, right: 0, bottom: 0,
114
+ backgroundColor: "rgba(0, 0, 0, 0.75)",
115
+ display: "flex",
116
+ alignItems: "center",
117
+ justifyContent: "center",
118
+ zIndex: 1000,
119
+ backdropFilter: "blur(4px)"
120
+ }}
121
+ >
122
+ <div
123
+ style={{
124
+ backgroundColor: "var(--surface-color)",
125
+ border: "1px solid var(--border-color)",
126
+ borderRadius: "12px",
127
+ width: "550px",
128
+ maxWidth: "90%",
129
+ padding: "24px",
130
+ position: "relative",
131
+ boxShadow: "0 8px 32px rgba(0, 0, 0, 0.5)",
132
+ maxHeight: "90vh",
133
+ overflowY: "auto"
134
+ }}
135
+ >
136
+ {/* Close Button */}
137
+ <button
138
+ onClick={onClose}
139
+ style={{
140
+ position: "absolute",
141
+ top: "16px", right: "16px",
142
+ background: "transparent",
143
+ border: "none",
144
+ color: "var(--text-secondary)",
145
+ fontSize: "1.5rem",
146
+ cursor: "pointer",
147
+ outline: "none"
148
+ }}
149
+ >
150
+ &times;
151
+ </button>
152
+
153
+ <h2 style={{ fontSize: "1.25rem", fontWeight: 700, marginBottom: "8px", display: "flex", alignItems: "center", gap: "8px" }}>
154
+ <span>🔗</span> Upload Relational Database
155
+ </h2>
156
+ <p style={{ color: "var(--text-secondary)", fontSize: "0.85rem", marginBottom: "20px" }}>
157
+ Provide a database file (.db) along with a schema DDL (.sql) or an ERD diagram image to start querying.
158
+ </p>
159
+
160
+ <form onSubmit={handleUploadSubmit} style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
161
+ {/* File Selection Zones Stacked Vertically */}
162
+ <div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
163
+
164
+ {/* Zone 1 — Schema File (.sql) — OPTIONAL */}
165
+ <div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
166
+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
167
+ <span style={{ fontSize: "0.85rem", fontWeight: "600", color: "var(--text-primary)" }}>
168
+ Schema File (.sql)
169
+ </span>
170
+ <span style={{ fontSize: "0.7rem", backgroundColor: "rgba(255,255,255,0.1)", padding: "2px 6px", borderRadius: "4px", color: "var(--text-muted)", fontWeight: "600" }}>
171
+ OPTIONAL
172
+ </span>
173
+ </div>
174
+ <div
175
+ onClick={() => sqlInputRef.current && sqlInputRef.current.click()}
176
+ className="drag-drop-zone"
177
+ style={{
178
+ padding: "16px 12px",
179
+ borderRadius: "8px",
180
+ borderColor: sqlFile ? "var(--success-color)" : "var(--border-color)",
181
+ borderStyle: "dashed",
182
+ borderWidth: "2px",
183
+ cursor: "pointer",
184
+ display: "flex",
185
+ flexDirection: "column",
186
+ alignItems: "center",
187
+ justifyContent: "center",
188
+ backgroundColor: "rgba(255, 255, 255, 0.02)"
189
+ }}
190
+ >
191
+ <input
192
+ type="file"
193
+ ref={sqlInputRef}
194
+ accept=".sql"
195
+ onChange={handleSqlFileChange}
196
+ style={{ display: "none" }}
197
+ />
198
+ <span style={{ fontSize: "1.3rem" }}>📄</span>
199
+ <strong style={{ fontSize: "0.8rem", color: "var(--text-primary)" }}>
200
+ {sqlFile ? sqlFile.name : "Schema (.sql)"}
201
+ </strong>
202
+ <span style={{ fontSize: "0.7rem", color: "var(--text-muted)", marginTop: "4px" }}>
203
+ (optional if ERD image provided)
204
+ </span>
205
+ </div>
206
+ </div>
207
+
208
+ {/* Zone 2 — ERD Diagram Image — OPTIONAL */}
209
+ <div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
210
+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
211
+ <span style={{ fontSize: "0.85rem", fontWeight: "600", color: "var(--text-primary)" }}>
212
+ ERD Diagram Image
213
+ </span>
214
+ <span style={{ fontSize: "0.7rem", backgroundColor: "rgba(255,255,255,0.1)", padding: "2px 6px", borderRadius: "4px", color: "var(--text-muted)", fontWeight: "600" }}>
215
+ OPTIONAL
216
+ </span>
217
+ </div>
218
+ <div
219
+ onClick={() => erdInputRef.current && erdInputRef.current.click()}
220
+ className="drag-drop-zone"
221
+ style={{
222
+ padding: "16px 12px",
223
+ borderRadius: "8px",
224
+ borderColor: erdImage ? "var(--success-color)" : "var(--border-color)",
225
+ borderStyle: "dashed",
226
+ borderWidth: "2px",
227
+ cursor: "pointer",
228
+ display: "flex",
229
+ flexDirection: "column",
230
+ alignItems: "center",
231
+ justifyContent: "center",
232
+ backgroundColor: "rgba(255, 255, 255, 0.02)"
233
+ }}
234
+ >
235
+ <input
236
+ type="file"
237
+ ref={erdInputRef}
238
+ accept=".png,.jpg,.jpeg,.pdf"
239
+ onChange={handleErdFileChange}
240
+ style={{ display: "none" }}
241
+ />
242
+ <span style={{ fontSize: "1.3rem" }}>🖼️</span>
243
+ <strong style={{ fontSize: "0.8rem", color: "var(--text-primary)" }}>
244
+ {erdImage ? erdImage.name : "ERD Diagram Image"}
245
+ </strong>
246
+ <span style={{ fontSize: "0.7rem", color: "var(--text-muted)", marginTop: "4px", textAlign: "center", padding: "0 12px" }}>
247
+ Upload your ERD diagram — we'll extract relationships, cardinality (1:1, 1:N, N:M), and participation constraints automatically
248
+ </span>
249
+ {erdImage && (
250
+ <div style={{ display: "flex", alignItems: "center", gap: "10px", marginTop: "10px", width: "100%", justifyContent: "center" }}>
251
+ {erdPreview && (
252
+ <img
253
+ src={erdPreview}
254
+ alt="ERD Thumbnail"
255
+ style={{ width: "36px", height: "36px", objectFit: "cover", borderRadius: "4px", border: "1px solid var(--border-color)" }}
256
+ />
257
+ )}
258
+ <span style={{ fontSize: "0.7rem", backgroundColor: "rgba(139, 92, 246, 0.2)", color: "#a78bfa", padding: "3px 8px", borderRadius: "12px", fontWeight: "600" }}>
259
+ 🤖 AI will analyze this image
260
+ </span>
261
+ </div>
262
+ )}
263
+ </div>
264
+ </div>
265
+
266
+ {/* Zone 3 — Database File (.db) — REQUIRED */}
267
+ <div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
268
+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
269
+ <span style={{ fontSize: "0.85rem", fontWeight: "600", color: "var(--text-primary)" }}>
270
+ Database File (.db)
271
+ </span>
272
+ <span style={{ fontSize: "0.7rem", backgroundColor: "rgba(239, 68, 68, 0.15)", color: "var(--error-color)", padding: "2px 6px", borderRadius: "4px", fontWeight: "600" }}>
273
+ REQUIRED
274
+ </span>
275
+ </div>
276
+ <div
277
+ onClick={() => dbInputRef.current && dbInputRef.current.click()}
278
+ className="drag-drop-zone"
279
+ style={{
280
+ padding: "16px 12px",
281
+ borderRadius: "8px",
282
+ borderColor: dbFile ? "var(--success-color)" : "var(--border-color)",
283
+ borderStyle: "dashed",
284
+ borderWidth: "2px",
285
+ cursor: "pointer",
286
+ display: "flex",
287
+ flexDirection: "column",
288
+ alignItems: "center",
289
+ justifyContent: "center",
290
+ backgroundColor: "rgba(255, 255, 255, 0.02)"
291
+ }}
292
+ >
293
+ <input
294
+ type="file"
295
+ ref={dbInputRef}
296
+ accept=".db,.sqlite"
297
+ onChange={handleDbFileChange}
298
+ style={{ display: "none" }}
299
+ />
300
+ <span style={{ fontSize: "1.3rem" }}>🗃️</span>
301
+ <strong style={{ fontSize: "0.8rem", color: "var(--text-primary)" }}>
302
+ {dbFile ? dbFile.name : "Database (.db)"}
303
+ </strong>
304
+ <span style={{ fontSize: "0.7rem", color: "var(--text-muted)", marginTop: "4px" }}>
305
+ SQLite database containing tables data
306
+ </span>
307
+ </div>
308
+ </div>
309
+
310
+ </div>
311
+
312
+ {/* Schema Preview Details Section */}
313
+ {(sqlFile || erdImage) && (
314
+ <div
315
+ style={{
316
+ backgroundColor: "var(--bg-color)",
317
+ border: "1px solid var(--border-color)",
318
+ borderRadius: "6px",
319
+ padding: "12px",
320
+ fontSize: "0.8rem",
321
+ lineHeight: "1.5"
322
+ }}
323
+ >
324
+ {sqlFile && !erdImage && (
325
+ <>
326
+ <div style={{ color: "var(--success-color)", fontWeight: 600 }}>
327
+ ✓ Detected {parsedTablesCount} tables and {parsedKeysCount} relationships
328
+ </div>
329
+ {parsedKeysCount === 0 && (
330
+ <div style={{ color: "var(--warning-color)", marginTop: "4px", fontSize: "0.75rem" }}>
331
+ ⚠️ No FOREIGN KEY constraints detected — schema mode works best with related tables.
332
+ </div>
333
+ )}
334
+ </>
335
+ )}
336
+ {erdImage && !sqlFile && (
337
+ <div style={{ color: "#a78bfa", fontWeight: 600 }}>
338
+ 🤖 Schema will be extracted from ERD image using AI vision
339
+ </div>
340
+ )}
341
+ {sqlFile && erdImage && (
342
+ <div style={{ color: "#a78bfa", fontWeight: 600 }}>
343
+ ✓ Schema from .sql file will be enriched with ERD relationship details
344
+ </div>
345
+ )}
346
+ </div>
347
+ )}
348
+
349
+ {/* Success Summary Info */}
350
+ {success && (
351
+ <div
352
+ style={{
353
+ backgroundColor: "rgba(16, 185, 129, 0.15)",
354
+ border: "1px solid var(--success-color)",
355
+ borderRadius: "6px",
356
+ padding: "12px",
357
+ color: "var(--success-color)",
358
+ fontSize: "0.85rem",
359
+ textAlign: "center"
360
+ }}
361
+ >
362
+ ✓ Database loaded. Transitioning workspace...
363
+ </div>
364
+ )}
365
+
366
+ {/* Error Details */}
367
+ {error && (
368
+ <div
369
+ style={{
370
+ backgroundColor: "rgba(239, 68, 68, 0.15)",
371
+ border: "1px solid var(--error-color)",
372
+ borderRadius: "6px",
373
+ padding: "12px",
374
+ color: "var(--error-color)",
375
+ fontSize: "0.8rem"
376
+ }}
377
+ >
378
+ <strong>Error:</strong> {error}
379
+ </div>
380
+ )}
381
+
382
+ {/* Submit Actions */}
383
+ <div style={{ display: "flex", justifyContent: "flex-end", gap: "10px", marginTop: "10px" }}>
384
+ <button
385
+ type="button"
386
+ className="btn-secondary"
387
+ onClick={onClose}
388
+ disabled={isUploading}
389
+ >
390
+ Cancel
391
+ </button>
392
+ <button
393
+ type="submit"
394
+ className="btn-primary"
395
+ disabled={(!sqlFile && !erdImage) || !dbFile || isUploading || success}
396
+ style={{
397
+ backgroundColor: "#8b5cf6",
398
+ display: "flex",
399
+ alignItems: "center",
400
+ gap: "8px"
401
+ }}
402
+ >
403
+ {isUploading ? (
404
+ <>
405
+ <div className="spinner" style={{ width: "14px", height: "14px", borderWidth: "2px" }} />
406
+ <span>Uploading...</span>
407
+ </>
408
+ ) : (
409
+ <span>
410
+ {sqlFile && !erdImage && "Upload Schema"}
411
+ {erdImage && !sqlFile && "Upload & Extract Schema from ERD"}
412
+ {sqlFile && erdImage && "Upload & Enrich with ERD"}
413
+ {!sqlFile && !erdImage && "Upload Database"}
414
+ </span>
415
+ )}
416
+ </button>
417
+ </div>
418
+ </form>
419
+ </div>
420
+ </div>
421
+ );
422
+ }
frontend/src/components/Sidebar.jsx ADDED
@@ -0,0 +1,324 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useRef, useState } from "react";
2
+ import { uploadCSV, deleteDataset, deleteSchemaDataset } from "../api";
3
+
4
+ export default function Sidebar({
5
+ datasets,
6
+ schemaDatasets = [],
7
+ selectedDataset,
8
+ onSelect,
9
+ onUploadSuccess,
10
+ onUploadSchemaClick,
11
+ onDelete,
12
+ history,
13
+ onRunQuestion,
14
+ addToast
15
+ }) {
16
+ const fileInputRef = useRef(null);
17
+ const [isUploading, setIsUploading] = useState(false);
18
+
19
+ const handleUploadClick = () => {
20
+ if (fileInputRef.current) {
21
+ fileInputRef.current.value = ""; // Reset file input
22
+ fileInputRef.current.click();
23
+ }
24
+ };
25
+
26
+ const handleFileChange = async (e) => {
27
+ const file = e.target.files[0];
28
+ if (!file) return;
29
+
30
+ if (!file.name.endsWith(".csv")) {
31
+ addToast("Only .csv files are allowed.", "error");
32
+ return;
33
+ }
34
+
35
+ setIsUploading(true);
36
+ try {
37
+ console.log("[Sidebar] Uploading CSV file:", file.name);
38
+ const result = await uploadCSV(file);
39
+ setIsUploading(false);
40
+ if (onUploadSuccess) {
41
+ onUploadSuccess(result);
42
+ }
43
+ } catch (err) {
44
+ setIsUploading(false);
45
+ addToast("Upload failed: " + err.message, "error");
46
+ }
47
+ };
48
+
49
+ const handleDeleteClick = async (e, dbName, mode = "csv") => {
50
+ e.stopPropagation(); // Avoid selecting the dataset card when deleting
51
+ if (window.confirm(`Are you sure you want to delete the dataset '${dbName}'?`)) {
52
+ try {
53
+ console.log(`[Sidebar] Deleting ${mode} dataset:`, dbName);
54
+ if (mode === "schema") {
55
+ await deleteSchemaDataset(dbName);
56
+ } else {
57
+ await deleteDataset(dbName);
58
+ }
59
+ if (onDelete) {
60
+ onDelete(dbName, mode);
61
+ }
62
+ } catch (err) {
63
+ addToast("Delete failed: " + err.message, "error");
64
+ }
65
+ }
66
+ };
67
+
68
+
69
+
70
+ // Separate sample vs user datasets
71
+ const sampleDatasets = datasets.filter((ds) => ds.is_sample);
72
+ const userDatasets = datasets.filter((ds) => !ds.is_sample);
73
+
74
+ return (
75
+ <aside className="sidebar">
76
+ <div className="sidebar-header">
77
+ <h1 className="sidebar-logo">
78
+ 🔍 NL Analyst<span>.</span>
79
+ </h1>
80
+ </div>
81
+
82
+ <div className="sidebar-content">
83
+ {/* Hidden File Input */}
84
+ <input
85
+ type="file"
86
+ ref={fileInputRef}
87
+ onChange={handleFileChange}
88
+ accept=".csv"
89
+ style={{ display: "none" }}
90
+ />
91
+
92
+ {/* ① "🔗 Relational Databases" section (purple #8b5cf6) — AT THE TOP */}
93
+ <div>
94
+ <div className="divider-label" style={{ color: "#8b5cf6", display: "flex", alignItems: "center", gap: "6px" }}>
95
+ <span>🔗</span> Relational Databases
96
+ </div>
97
+
98
+ <button
99
+ className="btn-primary"
100
+ onClick={onUploadSchemaClick}
101
+ style={{
102
+ width: "100%",
103
+ justifyContent: "center",
104
+ backgroundColor: "#8b5cf6",
105
+ marginBottom: "12px",
106
+ fontSize: "0.8rem",
107
+ padding: "8px 12px"
108
+ }}
109
+ onMouseEnter={(e) => e.target.style.backgroundColor = "#7c3aed"}
110
+ onMouseLeave={(e) => e.target.style.backgroundColor = "#8b5cf6"}
111
+ >
112
+ + Upload Schema DB
113
+ </button>
114
+
115
+ {schemaDatasets && schemaDatasets.length > 0 ? (
116
+ <div className="dataset-list">
117
+ {schemaDatasets.map((ds) => {
118
+ const isSelected = selectedDataset && selectedDataset.db_name === ds.db_name && selectedDataset.mode === "schema";
119
+ return (
120
+ <button
121
+ key={ds.db_name}
122
+ className={`dataset-item ${isSelected ? "selected" : ""}`}
123
+ onClick={() => onSelect({ ...ds, mode: "schema" })}
124
+ style={isSelected ? { borderLeftColor: "#8b5cf6", backgroundColor: "rgba(139, 92, 246, 0.1)" } : {}}
125
+ >
126
+ <div className="dataset-info-block">
127
+ <div className="dataset-title-row">
128
+ <span className="dataset-name">{ds.display_name}</span>
129
+ <span
130
+ className="badge"
131
+ style={{
132
+ backgroundColor: "rgba(139, 92, 246, 0.15)",
133
+ color: "#a78bfa",
134
+ border: "1px solid rgba(139, 92, 246, 0.3)"
135
+ }}
136
+ >
137
+ RELATIONAL
138
+ </span>
139
+ </div>
140
+ <span className="dataset-meta">
141
+ {ds.total_tables} tables • {ds.relationships ? ds.relationships.length : 0} relationships
142
+ </span>
143
+ </div>
144
+ {!ds.is_sample && (
145
+ <button
146
+ className="btn-delete-dataset"
147
+ onClick={(e) => handleDeleteClick(e, ds.db_name, "schema")}
148
+ title="Delete relational dataset"
149
+ >
150
+ <svg
151
+ xmlns="http://www.w3.org/2000/svg"
152
+ width="14"
153
+ height="14"
154
+ viewBox="0 0 24 24"
155
+ fill="none"
156
+ stroke="currentColor"
157
+ strokeWidth="2"
158
+ strokeLinecap="round"
159
+ strokeLinejoin="round"
160
+ >
161
+ <polyline points="3 6 5 6 21 6"></polyline>
162
+ <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
163
+ <line x1="10" y1="11" x2="10" y2="17"></line>
164
+ <line x1="14" y1="11" x2="14" y2="17"></line>
165
+ </svg>
166
+ </button>
167
+ )}
168
+ </button>
169
+ );
170
+ })}
171
+ </div>
172
+ ) : (
173
+ <div style={{ fontSize: "0.75rem", color: "var(--text-muted)", textAlign: "center", padding: "10px", border: "1px dashed var(--border-color)", borderRadius: "6px", marginBottom: "16px" }}>
174
+ Upload a .sql schema file to query relational data
175
+ </div>
176
+ )}
177
+ </div>
178
+
179
+ {/* ② Divider line */}
180
+ <hr style={{ border: "none", borderTop: "1px solid var(--border-color)", margin: "16px 0" }} />
181
+
182
+ {/* ③ "📊 CSV Datasets" section (blue #3b82f6) — IN THE MIDDLE */}
183
+ <div>
184
+ <div className="divider-label" style={{ color: "#3b82f6", display: "flex", alignItems: "center", gap: "6px" }}>
185
+ <span>📊</span> CSV Datasets
186
+ </div>
187
+
188
+ <button
189
+ className="btn-primary"
190
+ onClick={handleUploadClick}
191
+ disabled={isUploading}
192
+ style={{
193
+ width: "100%",
194
+ justifyContent: "center",
195
+ backgroundColor: "#3b82f6",
196
+ marginBottom: "12px"
197
+ }}
198
+ onMouseEnter={(e) => e.target.style.backgroundColor = "#2563eb"}
199
+ onMouseLeave={(e) => e.target.style.backgroundColor = "#3b82f6"}
200
+ >
201
+ {isUploading ? "Uploading..." : "+ Upload CSV"}
202
+ </button>
203
+
204
+ <div className="dataset-list">
205
+ {sampleDatasets.map((ds) => {
206
+ const isSelected = selectedDataset && selectedDataset.db_name === ds.db_name && selectedDataset.mode !== "schema";
207
+ return (
208
+ <button
209
+ key={ds.db_name}
210
+ className={`dataset-item ${isSelected ? "selected" : ""}`}
211
+ onClick={() => onSelect(ds)}
212
+ >
213
+ <div className="dataset-info-block">
214
+ <div className="dataset-title-row">
215
+ <span className="dataset-name">{ds.display_name}</span>
216
+ <span className="badge badge-sample">Sample</span>
217
+ </div>
218
+ <span className="dataset-meta">
219
+ {ds.row_count} rows • {ds.columns.length} columns
220
+ </span>
221
+ </div>
222
+ </button>
223
+ );
224
+ })}
225
+
226
+ {userDatasets.map((ds) => {
227
+ const isSelected = selectedDataset && selectedDataset.db_name === ds.db_name && selectedDataset.mode !== "schema";
228
+ return (
229
+ <button
230
+ key={ds.db_name}
231
+ className={`dataset-item ${isSelected ? "selected" : ""}`}
232
+ onClick={() => onSelect(ds)}
233
+ >
234
+ <div className="dataset-info-block">
235
+ <div className="dataset-title-row">
236
+ <span className="dataset-name">{ds.display_name}</span>
237
+ <span className="badge badge-user">User</span>
238
+ </div>
239
+ <span className="dataset-meta">
240
+ {ds.row_count} rows • {ds.columns.length} columns
241
+ </span>
242
+ </div>
243
+ <button
244
+ className="btn-delete-dataset"
245
+ onClick={(e) => handleDeleteClick(e, ds.db_name)}
246
+ title="Delete dataset"
247
+ >
248
+ <svg
249
+ xmlns="http://www.w3.org/2000/svg"
250
+ width="14"
251
+ height="14"
252
+ viewBox="0 0 24 24"
253
+ fill="none"
254
+ stroke="currentColor"
255
+ strokeWidth="2"
256
+ strokeLinecap="round"
257
+ strokeLinejoin="round"
258
+ >
259
+ <polyline points="3 6 5 6 21 6"></polyline>
260
+ <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
261
+ <line x1="10" y1="11" x2="10" y2="17"></line>
262
+ <line x1="14" y1="11" x2="14" y2="17"></line>
263
+ </svg>
264
+ </button>
265
+ </button>
266
+ );
267
+ })}
268
+ </div>
269
+ </div>
270
+
271
+
272
+ {/* Recent Queries Section */}
273
+ {selectedDataset && history && history.length > 0 && (
274
+ <div style={{ marginTop: "16px", borderTop: "1px solid var(--border-color)", paddingTop: "16px" }}>
275
+ <div className="divider-label">Recent Queries</div>
276
+ <div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
277
+ {history.slice(0, 5).map((item, idx) => (
278
+ <button
279
+ key={idx}
280
+ className="dataset-item"
281
+ onClick={() => onRunQuestion && onRunQuestion(item.question)}
282
+ style={{
283
+ padding: "8px 12px",
284
+ border: "1px solid var(--border-color)",
285
+ backgroundColor: "var(--surface-elevated)",
286
+ borderRadius: "6px"
287
+ }}
288
+ title={item.question}
289
+ >
290
+ <div style={{ display: "flex", alignItems: "center", gap: "8px", overflow: "hidden", width: "100%" }}>
291
+ <svg
292
+ xmlns="http://www.w3.org/2000/svg"
293
+ width="12"
294
+ height="12"
295
+ viewBox="0 0 24 24"
296
+ fill="none"
297
+ stroke="var(--text-muted)"
298
+ strokeWidth="2.5"
299
+ style={{ flexShrink: 0 }}
300
+ >
301
+ <circle cx="12" cy="12" r="10"></circle>
302
+ <polyline points="12 6 12 12 16 14"></polyline>
303
+ </svg>
304
+ <span
305
+ style={{
306
+ fontSize: "0.8rem",
307
+ whiteSpace: "nowrap",
308
+ overflow: "hidden",
309
+ textOverflow: "ellipsis",
310
+ color: "var(--text-secondary)"
311
+ }}
312
+ >
313
+ {item.question}
314
+ </span>
315
+ </div>
316
+ </button>
317
+ ))}
318
+ </div>
319
+ </div>
320
+ )}
321
+ </div>
322
+ </aside>
323
+ );
324
+ }
frontend/src/components/UploadView.jsx ADDED
@@ -0,0 +1,346 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useRef, useState } from "react";
2
+ import { uploadCSV, uploadSchemaDB } from "../api";
3
+
4
+ export default function UploadView({ onUploadSuccess, onSchemaUploadSuccess }) {
5
+ // CSV upload state
6
+ const [csvDragActive, setCsvDragActive] = useState(false);
7
+ const [csvState, setCsvState] = useState("idle"); // idle | uploading | success | error
8
+ const [csvError, setCsvError] = useState("");
9
+ const [csvInfo, setCsvInfo] = useState(null);
10
+ const csvInputRef = useRef(null);
11
+
12
+ // SQL upload state
13
+ const [sqlDragActive, setSqlDragActive] = useState(false);
14
+ const [sqlState, setSqlState] = useState("idle"); // idle | uploading | success | error
15
+ const [sqlError, setSqlError] = useState("");
16
+ const [sqlInfo, setSqlInfo] = useState(null);
17
+ const [sqlFile, setSqlFile] = useState(null);
18
+ const [erdFile, setErdFile] = useState(null);
19
+ const sqlInputRef = useRef(null);
20
+ const erdInputRef = useRef(null);
21
+
22
+ // ===================== CSV handlers =====================
23
+ const handleCsvDrag = (e) => {
24
+ e.preventDefault();
25
+ e.stopPropagation();
26
+ if (e.type === "dragenter" || e.type === "dragover") setCsvDragActive(true);
27
+ else if (e.type === "dragleave") setCsvDragActive(false);
28
+ };
29
+
30
+ const handleCsvDrop = async (e) => {
31
+ e.preventDefault();
32
+ e.stopPropagation();
33
+ setCsvDragActive(false);
34
+ if (e.dataTransfer.files && e.dataTransfer.files[0]) {
35
+ await processCsvFile(e.dataTransfer.files[0]);
36
+ }
37
+ };
38
+
39
+ const handleCsvFileChange = async (e) => {
40
+ if (e.target.files && e.target.files[0]) {
41
+ await processCsvFile(e.target.files[0]);
42
+ }
43
+ };
44
+
45
+ const processCsvFile = async (file) => {
46
+ if (!file.name.endsWith(".csv")) {
47
+ setCsvState("error");
48
+ setCsvError("Only .csv files are supported.");
49
+ return;
50
+ }
51
+ setCsvState("uploading");
52
+ setCsvError("");
53
+ try {
54
+ const result = await uploadCSV(file);
55
+ setCsvState("success");
56
+ setCsvInfo({ display_name: result.display_name, row_count: result.row_count, columns: result.columns.length });
57
+ setTimeout(() => {
58
+ if (onUploadSuccess) onUploadSuccess(result);
59
+ setCsvState("idle");
60
+ setCsvInfo(null);
61
+ }, 1500);
62
+ } catch (err) {
63
+ setCsvState("error");
64
+ setCsvError(err.message || "Failed to upload CSV.");
65
+ }
66
+ };
67
+
68
+ // ===================== SQL handlers =====================
69
+ const handleSqlDrag = (e) => {
70
+ e.preventDefault();
71
+ e.stopPropagation();
72
+ if (e.type === "dragenter" || e.type === "dragover") setSqlDragActive(true);
73
+ else if (e.type === "dragleave") setSqlDragActive(false);
74
+ };
75
+
76
+ const handleSqlDrop = async (e) => {
77
+ e.preventDefault();
78
+ e.stopPropagation();
79
+ setSqlDragActive(false);
80
+ if (e.dataTransfer.files && e.dataTransfer.files[0]) {
81
+ const file = e.dataTransfer.files[0];
82
+ if (file.name.endsWith(".sql")) {
83
+ setSqlFile(file);
84
+ } else if (file.name.match(/\.(png|jpg|jpeg)$/i)) {
85
+ setErdFile(file);
86
+ }
87
+ }
88
+ };
89
+
90
+ const handleSqlFileChange = (e) => {
91
+ if (e.target.files && e.target.files[0]) {
92
+ setSqlFile(e.target.files[0]);
93
+ }
94
+ };
95
+
96
+ const handleErdFileChange = (e) => {
97
+ if (e.target.files && e.target.files[0]) {
98
+ setErdFile(e.target.files[0]);
99
+ }
100
+ };
101
+
102
+ const handleSqlUpload = async () => {
103
+ if (!sqlFile) {
104
+ setSqlState("error");
105
+ setSqlError("A .sql file is required.");
106
+ return;
107
+ }
108
+ setSqlState("uploading");
109
+ setSqlError("");
110
+ try {
111
+ const result = await uploadSchemaDB(sqlFile, erdFile);
112
+ setSqlState("success");
113
+ setSqlInfo({ db_name: result.db_name, total_tables: result.total_tables, total_rows: result.total_rows });
114
+ setTimeout(() => {
115
+ if (onSchemaUploadSuccess) onSchemaUploadSuccess(result);
116
+ setSqlState("idle");
117
+ setSqlInfo(null);
118
+ setSqlFile(null);
119
+ setErdFile(null);
120
+ }, 1500);
121
+ } catch (err) {
122
+ setSqlState("error");
123
+ setSqlError(err.message || "Failed to upload SQL schema.");
124
+ }
125
+ };
126
+
127
+ return (
128
+ <div className="empty-state-container" style={{ maxWidth: "none", padding: "32px 24px" }}>
129
+ <h2 style={{ marginBottom: "6px" }}>Analyze Your Data</h2>
130
+ <p style={{ marginBottom: "28px", maxWidth: "600px", margin: "0 auto 28px" }}>
131
+ Upload a dataset to get started. Choose a CSV file for single-table data,
132
+ or a SQL schema file for multi-table relational databases.
133
+ </p>
134
+
135
+ {/* Two Side-by-Side Upload Cards */}
136
+ <div style={{
137
+ display: "flex",
138
+ gap: "20px",
139
+ width: "100%",
140
+ maxWidth: "900px",
141
+ margin: "0 auto",
142
+ flexWrap: "wrap"
143
+ }}>
144
+
145
+ {/* ─── LEFT CARD: CSV Dataset ─── */}
146
+ <div style={{
147
+ flex: "1 1 380px",
148
+ minWidth: "300px",
149
+ backgroundColor: "var(--surface-color)",
150
+ border: "1px solid var(--border-color)",
151
+ borderRadius: "12px",
152
+ padding: "24px",
153
+ display: "flex",
154
+ flexDirection: "column",
155
+ gap: "16px"
156
+ }}>
157
+ <div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
158
+ <span style={{ fontSize: "1.5rem" }}>📊</span>
159
+ <div>
160
+ <div style={{ fontWeight: 700, fontSize: "1rem", color: "var(--text-primary)" }}>CSV Dataset</div>
161
+ <div style={{ fontSize: "0.78rem", color: "var(--text-secondary)" }}>Single table data — sales, employees, any spreadsheet</div>
162
+ </div>
163
+ </div>
164
+
165
+ <input type="file" ref={csvInputRef} onChange={handleCsvFileChange} accept=".csv" style={{ display: "none" }} />
166
+
167
+ <div
168
+ className={`drag-drop-zone ${csvDragActive ? "active" : ""}`}
169
+ onDragEnter={handleCsvDrag}
170
+ onDragOver={handleCsvDrag}
171
+ onDragLeave={handleCsvDrag}
172
+ onDrop={handleCsvDrop}
173
+ onClick={() => csvInputRef.current && csvInputRef.current.click()}
174
+ style={{
175
+ borderColor: csvDragActive ? "#3b82f6" : "var(--border-color)",
176
+ minHeight: "130px",
177
+ display: "flex",
178
+ flexDirection: "column",
179
+ alignItems: "center",
180
+ justifyContent: "center",
181
+ gap: "8px",
182
+ transition: "all 0.2s"
183
+ }}
184
+ >
185
+ <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.7 }}>
186
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
187
+ <polyline points="17 8 12 3 7 8"></polyline>
188
+ <line x1="12" y1="3" x2="12" y2="15"></line>
189
+ </svg>
190
+ <span style={{ fontWeight: 600, fontSize: "0.85rem" }}>Drop a CSV file here</span>
191
+ <span style={{ fontSize: "0.72rem", color: "var(--text-muted)" }}>or click to browse • .csv files only</span>
192
+ </div>
193
+
194
+ <button
195
+ className="btn-primary"
196
+ onClick={() => csvInputRef.current && csvInputRef.current.click()}
197
+ disabled={csvState === "uploading"}
198
+ style={{
199
+ width: "100%",
200
+ justifyContent: "center",
201
+ backgroundColor: "#3b82f6",
202
+ fontSize: "0.85rem"
203
+ }}
204
+ onMouseEnter={(e) => e.target.style.backgroundColor = "#2563eb"}
205
+ onMouseLeave={(e) => e.target.style.backgroundColor = "#3b82f6"}
206
+ >
207
+ Browse CSV
208
+ </button>
209
+
210
+ {/* CSV Upload States */}
211
+ {csvState === "uploading" && (
212
+ <div className="upload-status uploading" style={{ fontSize: "0.82rem" }}>
213
+ <div className="spinner" style={{ width: "14px", height: "14px", borderWidth: "2px", display: "inline-block", marginRight: "8px", verticalAlign: "middle" }} />
214
+ Processing CSV...
215
+ </div>
216
+ )}
217
+ {csvState === "success" && csvInfo && (
218
+ <div className="upload-status success" style={{ fontSize: "0.82rem" }}>
219
+ ✓ Imported <strong>{csvInfo.display_name}</strong> — {csvInfo.row_count} rows, {csvInfo.columns} columns
220
+ </div>
221
+ )}
222
+ {csvState === "error" && (
223
+ <div className="upload-status error" style={{ fontSize: "0.82rem" }}>
224
+ ✕ {csvError}
225
+ </div>
226
+ )}
227
+ </div>
228
+
229
+ {/* ─── RIGHT CARD: Relational Database ─── */}
230
+ <div style={{
231
+ flex: "1 1 380px",
232
+ minWidth: "300px",
233
+ backgroundColor: "var(--surface-color)",
234
+ border: "1px solid var(--border-color)",
235
+ borderRadius: "12px",
236
+ padding: "24px",
237
+ display: "flex",
238
+ flexDirection: "column",
239
+ gap: "16px"
240
+ }}>
241
+ <div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
242
+ <span style={{ fontSize: "1.5rem" }}>🔗</span>
243
+ <div>
244
+ <div style={{ fontWeight: 700, fontSize: "1rem", color: "var(--text-primary)" }}>Relational Database</div>
245
+ <div style={{ fontSize: "0.78rem", color: "var(--text-secondary)" }}>T-SQL or SQLite schema with CREATE TABLE and INSERT statements</div>
246
+ </div>
247
+ </div>
248
+
249
+ <input type="file" ref={sqlInputRef} onChange={handleSqlFileChange} accept=".sql" style={{ display: "none" }} />
250
+ <input type="file" ref={erdInputRef} onChange={handleErdFileChange} accept=".png,.jpg,.jpeg" style={{ display: "none" }} />
251
+
252
+ {/* SQL Drop Zone (main) */}
253
+ <div
254
+ className={`drag-drop-zone ${sqlDragActive ? "active" : ""}`}
255
+ onDragEnter={handleSqlDrag}
256
+ onDragOver={handleSqlDrag}
257
+ onDragLeave={handleSqlDrag}
258
+ onDrop={handleSqlDrop}
259
+ onClick={() => sqlInputRef.current && sqlInputRef.current.click()}
260
+ style={{
261
+ borderColor: sqlFile ? "var(--success-color)" : (sqlDragActive ? "#8b5cf6" : "var(--border-color)"),
262
+ minHeight: "80px",
263
+ display: "flex",
264
+ flexDirection: "column",
265
+ alignItems: "center",
266
+ justifyContent: "center",
267
+ gap: "6px",
268
+ transition: "all 0.2s"
269
+ }}
270
+ >
271
+ <span style={{ fontSize: "1.1rem" }}>📄</span>
272
+ <span style={{ fontWeight: 600, fontSize: "0.82rem" }}>
273
+ {sqlFile ? `✓ ${sqlFile.name}` : "Drop a .sql file here"}
274
+ </span>
275
+ <span style={{ fontSize: "0.7rem", color: "var(--text-muted)" }}>
276
+ {sqlFile ? "Click to change" : "Schema file (required)"}
277
+ </span>
278
+ </div>
279
+
280
+ {/* ERD Image Zone (optional, smaller) */}
281
+ <div
282
+ className="drag-drop-zone"
283
+ onClick={() => erdInputRef.current && erdInputRef.current.click()}
284
+ style={{
285
+ borderColor: erdFile ? "var(--success-color)" : "var(--border-color)",
286
+ minHeight: "50px",
287
+ padding: "10px",
288
+ display: "flex",
289
+ flexDirection: "row",
290
+ alignItems: "center",
291
+ justifyContent: "center",
292
+ gap: "8px",
293
+ cursor: "pointer"
294
+ }}
295
+ >
296
+ <span style={{ fontSize: "0.9rem" }}>🖼️</span>
297
+ <span style={{ fontSize: "0.78rem", color: erdFile ? "var(--success-color)" : "var(--text-muted)" }}>
298
+ {erdFile ? `✓ ${erdFile.name}` : "ERD image — .png/.jpg (optional)"}
299
+ </span>
300
+ </div>
301
+
302
+ <button
303
+ className="btn-primary"
304
+ onClick={handleSqlUpload}
305
+ disabled={!sqlFile || sqlState === "uploading"}
306
+ style={{
307
+ width: "100%",
308
+ justifyContent: "center",
309
+ backgroundColor: "#8b5cf6",
310
+ fontSize: "0.85rem",
311
+ opacity: (!sqlFile || sqlState === "uploading") ? 0.5 : 1
312
+ }}
313
+ onMouseEnter={(e) => { if (sqlFile) e.target.style.backgroundColor = "#7c3aed"; }}
314
+ onMouseLeave={(e) => e.target.style.backgroundColor = "#8b5cf6"}
315
+ >
316
+ {sqlState === "uploading" ? (
317
+ <span style={{ display: "flex", alignItems: "center", gap: "8px", justifyContent: "center" }}>
318
+ <span className="spinner" style={{ width: "14px", height: "14px", borderWidth: "2px" }} />
319
+ Processing...
320
+ </span>
321
+ ) : (
322
+ "Upload SQL Schema"
323
+ )}
324
+ </button>
325
+
326
+ {/* SQL Upload States */}
327
+ {sqlState === "success" && sqlInfo && (
328
+ <div className="upload-status success" style={{ fontSize: "0.82rem" }}>
329
+ ✓ Created <strong>{sqlInfo.db_name}</strong> — {sqlInfo.total_tables} tables, {sqlInfo.total_rows.toLocaleString()} rows
330
+ </div>
331
+ )}
332
+ {sqlState === "error" && (
333
+ <div className="upload-status error" style={{ fontSize: "0.82rem" }}>
334
+ ✕ {sqlError}
335
+ </div>
336
+ )}
337
+ </div>
338
+ </div>
339
+
340
+ {/* Bottom hint */}
341
+ <p style={{ fontSize: "0.75rem", color: "var(--text-muted)", marginTop: "20px", textAlign: "center" }}>
342
+ Or select an existing sample dataset from the sidebar to start querying immediately.
343
+ </p>
344
+ </div>
345
+ );
346
+ }
frontend/src/index.css ADDED
@@ -0,0 +1,924 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap');
2
+
3
+ /* Color Variables and Theme Tokens */
4
+ :root {
5
+ --bg-color: #0d0d0d;
6
+ --surface-color: #161616;
7
+ --surface-elevated: #1f1f1f;
8
+ --border-color: #2c2c2c;
9
+ --primary-color: #3b82f6;
10
+ --primary-glow: rgba(59, 130, 246, 0.15);
11
+ --success-color: #10b981;
12
+ --warning-color: #f59e0b;
13
+ --error-color: #ef4444;
14
+ --text-primary: #f9fafb;
15
+ --text-secondary: #9ca3af;
16
+ --text-muted: #6b7280;
17
+
18
+ --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
19
+ --font-mono: 'JetBrains Mono', SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
20
+
21
+ --sidebar-width: 300px;
22
+ --transition-speed: 0.2s;
23
+ }
24
+
25
+ /* Reset and Base Styles */
26
+ * {
27
+ box-sizing: border-box;
28
+ margin: 0;
29
+ padding: 0;
30
+ }
31
+
32
+ body {
33
+ background-color: var(--bg-color);
34
+ color: var(--text-primary);
35
+ font-family: var(--font-sans);
36
+ -webkit-font-smoothing: antialiased;
37
+ -moz-osx-font-smoothing: grayscale;
38
+ overflow: hidden;
39
+ height: 100vh;
40
+ }
41
+
42
+ /* Custom Scrollbars */
43
+ ::-webkit-scrollbar {
44
+ width: 8px;
45
+ height: 8px;
46
+ }
47
+
48
+ ::-webkit-scrollbar-track {
49
+ background: var(--bg-color);
50
+ }
51
+
52
+ ::-webkit-scrollbar-thumb {
53
+ background: var(--border-color);
54
+ border-radius: 4px;
55
+ }
56
+
57
+ ::-webkit-scrollbar-thumb:hover {
58
+ background: var(--text-muted);
59
+ }
60
+
61
+ /* Two-Panel Layout */
62
+ .app-container {
63
+ display: flex;
64
+ height: 100vh;
65
+ width: 100vw;
66
+ background-color: var(--bg-color);
67
+ }
68
+
69
+ /* Sidebar Styles */
70
+ .sidebar {
71
+ width: var(--sidebar-width);
72
+ min-width: var(--sidebar-width);
73
+ height: 100%;
74
+ background-color: var(--surface-color);
75
+ border-right: 1px solid var(--border-color);
76
+ display: flex;
77
+ flex-direction: column;
78
+ overflow-y: auto;
79
+ }
80
+
81
+ .sidebar-header {
82
+ padding: 20px;
83
+ border-bottom: 1px solid var(--border-color);
84
+ display: flex;
85
+ align-items: center;
86
+ gap: 10px;
87
+ }
88
+
89
+ .sidebar-logo {
90
+ font-size: 1.25rem;
91
+ font-weight: 700;
92
+ color: var(--text-primary);
93
+ letter-spacing: -0.025em;
94
+ display: flex;
95
+ align-items: center;
96
+ }
97
+
98
+ .sidebar-logo span {
99
+ color: var(--primary-color);
100
+ margin-left: 4px;
101
+ }
102
+
103
+ .sidebar-content {
104
+ padding: 16px;
105
+ display: flex;
106
+ flex-direction: column;
107
+ gap: 20px;
108
+ flex-grow: 1;
109
+ }
110
+
111
+ .divider-label {
112
+ font-size: 0.7rem;
113
+ font-weight: 700;
114
+ text-transform: uppercase;
115
+ color: var(--text-muted);
116
+ letter-spacing: 0.05em;
117
+ margin-bottom: 8px;
118
+ margin-top: 10px;
119
+ }
120
+
121
+ .dataset-list {
122
+ display: flex;
123
+ flex-direction: column;
124
+ gap: 6px;
125
+ }
126
+
127
+ /* Dataset Card Item */
128
+ .dataset-item {
129
+ background-color: transparent;
130
+ border: 1px solid transparent;
131
+ border-radius: 6px;
132
+ padding: 12px;
133
+ text-align: left;
134
+ cursor: pointer;
135
+ display: flex;
136
+ justify-content: space-between;
137
+ align-items: center;
138
+ transition: all var(--transition-speed) ease;
139
+ width: 100%;
140
+ color: var(--text-primary);
141
+ }
142
+
143
+ .dataset-item:hover {
144
+ background-color: var(--surface-elevated);
145
+ border-color: var(--border-color);
146
+ }
147
+
148
+ .dataset-item.selected {
149
+ background-color: var(--primary-glow);
150
+ border-left: 3px solid var(--primary-color);
151
+ border-top-color: transparent;
152
+ border-right-color: transparent;
153
+ border-bottom-color: transparent;
154
+ }
155
+
156
+ .dataset-info-block {
157
+ display: flex;
158
+ flex-direction: column;
159
+ gap: 4px;
160
+ overflow: hidden;
161
+ flex-grow: 1;
162
+ }
163
+
164
+ .dataset-title-row {
165
+ display: flex;
166
+ align-items: center;
167
+ gap: 6px;
168
+ overflow: hidden;
169
+ }
170
+
171
+ .dataset-name {
172
+ font-weight: 600;
173
+ font-size: 0.9rem;
174
+ white-space: nowrap;
175
+ overflow: hidden;
176
+ text-overflow: ellipsis;
177
+ }
178
+
179
+ .dataset-meta {
180
+ font-size: 0.75rem;
181
+ color: var(--text-secondary);
182
+ }
183
+
184
+ /* Badges */
185
+ .badge {
186
+ font-size: 0.65rem;
187
+ font-weight: 700;
188
+ padding: 2px 6px;
189
+ border-radius: 4px;
190
+ text-transform: uppercase;
191
+ }
192
+
193
+ .badge-sample {
194
+ background-color: rgba(59, 130, 246, 0.15);
195
+ color: var(--primary-color);
196
+ border: 1px solid rgba(59, 130, 246, 0.3);
197
+ }
198
+
199
+ .badge-user {
200
+ background-color: rgba(16, 185, 129, 0.15);
201
+ color: var(--success-color);
202
+ border: 1px solid rgba(16, 185, 129, 0.3);
203
+ }
204
+
205
+ .badge-type {
206
+ font-family: var(--font-mono);
207
+ font-weight: 500;
208
+ }
209
+
210
+ .badge-type.text {
211
+ background-color: rgba(156, 163, 175, 0.15);
212
+ color: var(--text-secondary);
213
+ }
214
+
215
+ .badge-type.integer, .badge-type.real {
216
+ background-color: rgba(59, 130, 246, 0.15);
217
+ color: var(--primary-color);
218
+ }
219
+
220
+ .badge-type.date {
221
+ background-color: rgba(167, 139, 250, 0.15);
222
+ color: #a78bfa;
223
+ }
224
+
225
+ /* Delete Button on User Dataset Card */
226
+ .btn-delete-dataset {
227
+ background: transparent;
228
+ border: none;
229
+ cursor: pointer;
230
+ color: var(--text-muted);
231
+ padding: 4px;
232
+ border-radius: 4px;
233
+ display: flex;
234
+ align-items: center;
235
+ justify-content: center;
236
+ transition: all var(--transition-speed) ease;
237
+ }
238
+
239
+ .btn-delete-dataset:hover {
240
+ color: var(--error-color);
241
+ background-color: rgba(239, 68, 68, 0.1);
242
+ }
243
+
244
+ /* Main Area Container */
245
+ .main-content {
246
+ flex-grow: 1;
247
+ height: 100%;
248
+ overflow-y: auto;
249
+ padding: 32px;
250
+ display: flex;
251
+ flex-direction: column;
252
+ }
253
+
254
+ /* Upload & Empty View */
255
+ .empty-state-container {
256
+ display: flex;
257
+ flex-direction: column;
258
+ justify-content: center;
259
+ align-items: center;
260
+ flex-grow: 1;
261
+ max-width: 600px;
262
+ margin: 0 auto;
263
+ text-align: center;
264
+ gap: 24px;
265
+ width: 100%;
266
+ }
267
+
268
+ .empty-state-container h2 {
269
+ font-size: 1.75rem;
270
+ font-weight: 700;
271
+ color: var(--text-primary);
272
+ }
273
+
274
+ .empty-state-container p {
275
+ color: var(--text-secondary);
276
+ font-size: 0.95rem;
277
+ line-height: 1.5;
278
+ }
279
+
280
+ .drag-drop-zone {
281
+ border: 2px dashed var(--border-color);
282
+ border-radius: 12px;
283
+ background-color: var(--surface-color);
284
+ padding: 48px;
285
+ display: flex;
286
+ flex-direction: column;
287
+ align-items: center;
288
+ justify-content: center;
289
+ gap: 16px;
290
+ cursor: pointer;
291
+ transition: all var(--transition-speed) ease;
292
+ width: 100%;
293
+ }
294
+
295
+ .drag-drop-zone:hover, .drag-drop-zone.active {
296
+ border-color: var(--primary-color);
297
+ background-color: var(--surface-elevated);
298
+ }
299
+
300
+ .upload-icon {
301
+ font-size: 2.5rem;
302
+ color: var(--text-muted);
303
+ }
304
+
305
+ .drag-drop-zone:hover .upload-icon {
306
+ color: var(--primary-color);
307
+ }
308
+
309
+ .btn-primary {
310
+ background-color: var(--primary-color);
311
+ color: var(--text-primary);
312
+ border: none;
313
+ border-radius: 6px;
314
+ padding: 10px 18px;
315
+ font-weight: 600;
316
+ font-size: 0.9rem;
317
+ cursor: pointer;
318
+ display: flex;
319
+ align-items: center;
320
+ gap: 8px;
321
+ transition: background-color var(--transition-speed) ease;
322
+ }
323
+
324
+ .btn-primary:hover:not(:disabled) {
325
+ background-color: #2563eb;
326
+ }
327
+
328
+ .btn-primary:disabled {
329
+ opacity: 0.6;
330
+ cursor: not-allowed;
331
+ }
332
+
333
+ /* Uploading States */
334
+ .upload-status {
335
+ padding: 12px 16px;
336
+ border-radius: 6px;
337
+ font-size: 0.9rem;
338
+ font-weight: 500;
339
+ width: 100%;
340
+ text-align: left;
341
+ }
342
+
343
+ .upload-status.uploading {
344
+ background-color: var(--primary-glow);
345
+ color: var(--primary-color);
346
+ border: 1px solid rgba(59, 130, 246, 0.2);
347
+ }
348
+
349
+ .upload-status.success {
350
+ background-color: rgba(16, 185, 129, 0.1);
351
+ color: var(--success-color);
352
+ border: 1px solid rgba(16, 185, 129, 0.2);
353
+ }
354
+
355
+ .upload-status.error {
356
+ background-color: rgba(239, 68, 68, 0.1);
357
+ color: var(--error-color);
358
+ border: 1px solid rgba(239, 68, 68, 0.2);
359
+ }
360
+
361
+ /* Data Preview Layout */
362
+ .preview-header {
363
+ display: flex;
364
+ justify-content: space-between;
365
+ align-items: flex-start;
366
+ margin-bottom: 24px;
367
+ border-bottom: 1px solid var(--border-color);
368
+ padding-bottom: 24px;
369
+ }
370
+
371
+ .preview-title-area {
372
+ display: flex;
373
+ flex-direction: column;
374
+ gap: 8px;
375
+ }
376
+
377
+ .preview-title-area h1 {
378
+ font-size: 1.8rem;
379
+ font-weight: 700;
380
+ }
381
+
382
+ .preview-description {
383
+ color: var(--text-secondary);
384
+ font-size: 0.95rem;
385
+ }
386
+
387
+ .stat-badges {
388
+ display: flex;
389
+ gap: 12px;
390
+ margin-top: 6px;
391
+ }
392
+
393
+ .stat-badge {
394
+ background-color: var(--surface-color);
395
+ border: 1px solid var(--border-color);
396
+ padding: 6px 12px;
397
+ border-radius: 6px;
398
+ font-size: 0.8rem;
399
+ font-weight: 500;
400
+ display: flex;
401
+ gap: 6px;
402
+ }
403
+
404
+ .stat-badge span:first-child {
405
+ color: var(--text-secondary);
406
+ }
407
+
408
+ .stat-badge span:last-child {
409
+ color: var(--text-primary);
410
+ font-weight: 700;
411
+ }
412
+
413
+ /* Section Dividers */
414
+ .section-title {
415
+ font-size: 1.1rem;
416
+ font-weight: 600;
417
+ color: var(--text-primary);
418
+ margin-bottom: 12px;
419
+ margin-top: 24px;
420
+ }
421
+
422
+ /* General Grid layout for schema */
423
+ .schema-grid {
424
+ display: grid;
425
+ grid-template-columns: 1fr;
426
+ gap: 24px;
427
+ }
428
+
429
+ /* General Tables */
430
+ .table-container {
431
+ border: 1px solid var(--border-color);
432
+ border-radius: 8px;
433
+ overflow: auto;
434
+ background-color: var(--surface-color);
435
+ max-width: 100%;
436
+ }
437
+
438
+ .table-preview {
439
+ width: 100%;
440
+ border-collapse: collapse;
441
+ font-size: 0.85rem;
442
+ text-align: left;
443
+ }
444
+
445
+ .table-preview th {
446
+ background-color: var(--surface-elevated);
447
+ color: var(--text-secondary);
448
+ font-weight: 600;
449
+ padding: 10px 14px;
450
+ border-bottom: 1px solid var(--border-color);
451
+ position: sticky;
452
+ top: 0;
453
+ z-index: 10;
454
+ }
455
+
456
+ .table-preview td {
457
+ padding: 10px 14px;
458
+ border-bottom: 1px solid var(--border-color);
459
+ color: var(--text-primary);
460
+ }
461
+
462
+ .table-preview tr:last-child td {
463
+ border-bottom: none;
464
+ }
465
+
466
+ /* Zebra striping for Tables */
467
+ .table-preview tbody tr:nth-child(even) {
468
+ background-color: #1a1a1a;
469
+ }
470
+
471
+ .table-preview tbody tr:nth-child(odd) {
472
+ background-color: #161616;
473
+ }
474
+
475
+ /* Text alignment & Monospace for cells */
476
+ .cell-numeric {
477
+ text-align: right !important;
478
+ }
479
+
480
+ .cell-text {
481
+ text-align: left !important;
482
+ }
483
+
484
+ .cell-mono {
485
+ font-family: var(--font-mono);
486
+ font-size: 0.8rem;
487
+ }
488
+
489
+ /* Query View Styles */
490
+ .query-container {
491
+ display: flex;
492
+ flex-direction: column;
493
+ gap: 24px;
494
+ flex-grow: 1;
495
+ }
496
+
497
+ .query-input-section {
498
+ background-color: var(--surface-color);
499
+ border: 1px solid var(--border-color);
500
+ border-radius: 8px;
501
+ padding: 20px;
502
+ display: flex;
503
+ flex-direction: column;
504
+ gap: 16px;
505
+ }
506
+
507
+ .query-input-row {
508
+ display: flex;
509
+ gap: 12px;
510
+ }
511
+
512
+ .query-input {
513
+ flex-grow: 1;
514
+ background-color: var(--bg-color);
515
+ border: 1px solid var(--border-color);
516
+ border-radius: 6px;
517
+ color: var(--text-primary);
518
+ padding: 12px;
519
+ font-size: 0.95rem;
520
+ font-family: var(--font-sans);
521
+ transition: border-color var(--transition-speed) ease;
522
+ outline: none;
523
+ }
524
+
525
+ .query-input:focus {
526
+ border-color: var(--primary-color);
527
+ }
528
+
529
+ /* Query suggestions */
530
+ .suggestions-list {
531
+ display: flex;
532
+ flex-wrap: wrap;
533
+ gap: 8px;
534
+ }
535
+
536
+ .suggestion-chip {
537
+ background-color: var(--surface-elevated);
538
+ border: 1px solid var(--border-color);
539
+ color: var(--text-secondary);
540
+ border-radius: 100px;
541
+ padding: 6px 12px;
542
+ font-size: 0.8rem;
543
+ cursor: pointer;
544
+ transition: all var(--transition-speed) ease;
545
+ }
546
+
547
+ .suggestion-chip:hover {
548
+ border-color: var(--primary-color);
549
+ color: var(--text-primary);
550
+ }
551
+
552
+ /* Main outputs of a query */
553
+ .results-grid {
554
+ display: grid;
555
+ grid-template-columns: 1fr;
556
+ gap: 24px;
557
+ }
558
+
559
+ @media (min-width: 1024px) {
560
+ .results-grid {
561
+ grid-template-columns: 1.5fr 1fr;
562
+ }
563
+ }
564
+
565
+ .results-left {
566
+ display: flex;
567
+ flex-direction: column;
568
+ gap: 24px;
569
+ }
570
+
571
+ .results-right {
572
+ display: flex;
573
+ flex-direction: column;
574
+ gap: 24px;
575
+ }
576
+
577
+ /* Cards for Query Outputs */
578
+ .output-card {
579
+ background-color: var(--surface-color);
580
+ border: 1px solid var(--border-color);
581
+ border-radius: 8px;
582
+ padding: 20px;
583
+ display: flex;
584
+ flex-direction: column;
585
+ gap: 12px;
586
+ }
587
+
588
+ .output-card-header {
589
+ display: flex;
590
+ justify-content: space-between;
591
+ align-items: center;
592
+ border-bottom: 1px solid var(--border-color);
593
+ padding-bottom: 10px;
594
+ }
595
+
596
+ .output-card-title {
597
+ font-size: 0.9rem;
598
+ font-weight: 700;
599
+ text-transform: uppercase;
600
+ color: var(--text-secondary);
601
+ letter-spacing: 0.05em;
602
+ display: flex;
603
+ align-items: center;
604
+ gap: 8px;
605
+ }
606
+
607
+ /* Monospace Code display */
608
+ .code-block {
609
+ font-family: var(--font-mono);
610
+ background-color: var(--bg-color);
611
+ border: 1px solid var(--border-color);
612
+ padding: 16px;
613
+ border-radius: 6px;
614
+ color: #38bdf8; /* sky blue code color */
615
+ font-size: 0.85rem;
616
+ line-height: 1.5;
617
+ white-space: pre-wrap;
618
+ word-break: break-all;
619
+ overflow-x: auto;
620
+ }
621
+
622
+ /* Insight Paragraph */
623
+ .insight-text {
624
+ font-size: 0.95rem;
625
+ line-height: 1.6;
626
+ color: var(--text-primary);
627
+ }
628
+
629
+ /* SVG Chart Styles */
630
+ .chart-container {
631
+ display: flex;
632
+ justify-content: center;
633
+ align-items: center;
634
+ width: 100%;
635
+ height: 280px;
636
+ background-color: var(--bg-color);
637
+ border: 1px solid var(--border-color);
638
+ border-radius: 6px;
639
+ padding: 16px;
640
+ }
641
+
642
+ .chart-svg {
643
+ width: 100%;
644
+ height: 100%;
645
+ }
646
+
647
+ .chart-bar {
648
+ fill: var(--primary-color);
649
+ transition: fill var(--transition-speed) ease, height var(--transition-speed) ease, y var(--transition-speed) ease;
650
+ cursor: pointer;
651
+ }
652
+
653
+ .chart-bar:hover {
654
+ fill: #60a5fa;
655
+ }
656
+
657
+ .chart-axis-line {
658
+ stroke: var(--border-color);
659
+ stroke-width: 1px;
660
+ }
661
+
662
+ .chart-grid-line {
663
+ stroke: rgba(44, 44, 44, 0.5);
664
+ stroke-width: 1px;
665
+ stroke-dasharray: 4;
666
+ }
667
+
668
+ .chart-text {
669
+ fill: var(--text-secondary);
670
+ font-size: 10px;
671
+ font-family: var(--font-sans);
672
+ }
673
+
674
+ .chart-text-title {
675
+ fill: var(--text-primary);
676
+ font-size: 11px;
677
+ font-weight: 600;
678
+ }
679
+
680
+ /* Loading animations */
681
+ .loading-container {
682
+ display: flex;
683
+ flex-direction: column;
684
+ justify-content: center;
685
+ align-items: center;
686
+ gap: 16px;
687
+ padding: 60px 0;
688
+ flex-grow: 1;
689
+ }
690
+
691
+ .spinner {
692
+ width: 32px;
693
+ height: 32px;
694
+ border: 3px solid var(--border-color);
695
+ border-top-color: var(--primary-color);
696
+ border-radius: 50%;
697
+ animation: spin 0.8s linear infinite;
698
+ }
699
+
700
+ @keyframes spin {
701
+ to {
702
+ transform: rotate(360deg);
703
+ }
704
+ }
705
+
706
+ /* Shimmer Loading placeholders */
707
+ .shimmer {
708
+ background: linear-gradient(90deg, var(--surface-color) 25%, var(--surface-elevated) 50%, var(--surface-color) 75%);
709
+ background-size: 200% 100%;
710
+ animation: loading-shimmer 1.5s infinite;
711
+ }
712
+
713
+ @keyframes loading-shimmer {
714
+ 0% {
715
+ background-position: 200% 0;
716
+ }
717
+ 100% {
718
+ background-position: -200% 0;
719
+ }
720
+ }
721
+
722
+ /* Utility buttons */
723
+ .btn-secondary {
724
+ background-color: var(--surface-color);
725
+ border: 1px solid var(--border-color);
726
+ color: var(--text-primary);
727
+ padding: 10px 18px;
728
+ border-radius: 6px;
729
+ font-weight: 600;
730
+ font-size: 0.9rem;
731
+ cursor: pointer;
732
+ transition: all var(--transition-speed) ease;
733
+ }
734
+
735
+ .btn-secondary:hover {
736
+ background-color: var(--surface-elevated);
737
+ border-color: var(--text-secondary);
738
+ }
739
+
740
+ /* Header Action View Switches */
741
+ .view-switch-tabs {
742
+ display: flex;
743
+ background-color: var(--surface-color);
744
+ border: 1px solid var(--border-color);
745
+ border-radius: 6px;
746
+ padding: 2px;
747
+ }
748
+
749
+ .view-tab {
750
+ background: transparent;
751
+ border: none;
752
+ color: var(--text-secondary);
753
+ padding: 6px 12px;
754
+ font-size: 0.8rem;
755
+ font-weight: 600;
756
+ border-radius: 4px;
757
+ cursor: pointer;
758
+ transition: all var(--transition-speed) ease;
759
+ }
760
+
761
+ .view-tab.active {
762
+ background-color: var(--surface-elevated);
763
+ color: var(--text-primary);
764
+ box-shadow: 0 1px 3px rgba(0,0,0,0.2);
765
+ }
766
+
767
+ /* Stats Bar */
768
+ .stats-bar {
769
+ height: 40px;
770
+ min-height: 40px;
771
+ background-color: var(--surface-color);
772
+ border-bottom: 1px solid var(--border-color);
773
+ display: flex;
774
+ align-items: center;
775
+ justify-content: space-between;
776
+ padding: 0 24px;
777
+ font-size: 0.8rem;
778
+ color: var(--text-secondary);
779
+ font-weight: 500;
780
+ z-index: 100;
781
+ }
782
+
783
+ /* Toast Notification System */
784
+ .toast-container {
785
+ position: fixed;
786
+ top: 20px;
787
+ right: 20px;
788
+ display: flex;
789
+ flex-direction: column;
790
+ gap: 10px;
791
+ z-index: 9999;
792
+ pointer-events: none;
793
+ }
794
+
795
+ .toast {
796
+ pointer-events: auto;
797
+ width: 320px;
798
+ padding: 14px 18px;
799
+ border-radius: 8px;
800
+ font-size: 0.85rem;
801
+ font-weight: 500;
802
+ display: flex;
803
+ justify-content: space-between;
804
+ align-items: center;
805
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
806
+ animation: slide-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
807
+ transition: opacity 0.3s ease;
808
+ }
809
+
810
+ .toast-success {
811
+ background-color: #0f1c18;
812
+ border: 1px solid rgba(16, 185, 129, 0.3);
813
+ color: var(--success-color);
814
+ }
815
+
816
+ .toast-error {
817
+ background-color: #1c1012;
818
+ border: 1px solid rgba(239, 68, 68, 0.3);
819
+ color: var(--error-color);
820
+ }
821
+
822
+ .toast-info {
823
+ background-color: #101524;
824
+ border: 1px solid rgba(59, 130, 246, 0.3);
825
+ color: var(--primary-color);
826
+ }
827
+
828
+ .toast-close-btn {
829
+ background: transparent;
830
+ border: none;
831
+ color: inherit;
832
+ opacity: 0.7;
833
+ cursor: pointer;
834
+ padding: 2px;
835
+ display: flex;
836
+ align-items: center;
837
+ justify-content: center;
838
+ border-radius: 4px;
839
+ }
840
+
841
+ .toast-close-btn:hover {
842
+ opacity: 1;
843
+ background-color: rgba(255, 255, 255, 0.05);
844
+ }
845
+
846
+ @keyframes slide-in {
847
+ from {
848
+ transform: translateX(120%);
849
+ opacity: 0;
850
+ }
851
+ to {
852
+ transform: translateX(0);
853
+ opacity: 1;
854
+ }
855
+ }
856
+
857
+ /* Suggestions Row Scrollbar */
858
+ .suggestions-scroll-container {
859
+ display: flex;
860
+ overflow-x: auto;
861
+ width: 100%;
862
+ gap: 8px;
863
+ padding-bottom: 6px;
864
+ margin-top: 4px;
865
+ }
866
+
867
+ .suggestions-scroll-container::-webkit-scrollbar {
868
+ height: 4px;
869
+ }
870
+
871
+ .suggestions-scroll-container::-webkit-scrollbar-thumb {
872
+ background: var(--border-color);
873
+ border-radius: 2px;
874
+ }
875
+
876
+ /* Responsive Styles */
877
+ @media (max-width: 767px) {
878
+ .sidebar {
879
+ display: none !important;
880
+ }
881
+
882
+ .main-content {
883
+ padding: 16px !important;
884
+ }
885
+
886
+ .app-container {
887
+ flex-direction: column;
888
+ }
889
+
890
+ .results-grid {
891
+ grid-template-columns: 1fr !important;
892
+ }
893
+
894
+ .preview-header {
895
+ flex-direction: column;
896
+ gap: 16px;
897
+ align-items: stretch;
898
+ }
899
+
900
+ .stats-bar {
901
+ padding: 0 16px;
902
+ font-size: 0.75rem;
903
+ }
904
+
905
+ /* Reduce Chart height */
906
+ .chart-container {
907
+ height: 220px !important;
908
+ }
909
+ }
910
+
911
+ @media (min-width: 768px) and (max-width: 1024px) {
912
+ :root {
913
+ --sidebar-width: 220px;
914
+ }
915
+
916
+ .sidebar-logo {
917
+ font-size: 1.1rem;
918
+ }
919
+
920
+ .sidebar-content {
921
+ padding: 12px;
922
+ }
923
+ }
924
+
frontend/src/main.jsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { StrictMode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import './index.css'
4
+ import App from './App.jsx'
5
+
6
+ createRoot(document.getElementById('root')).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ )
frontend/vite.config.js ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ // https://vite.dev/config/
5
+ export default defineConfig({
6
+ plugins: [react()],
7
+ })
run_tests.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import json
4
+
5
+ # Ensure parent directory is in python path
6
+ current_dir = os.path.dirname(os.path.abspath(__file__))
7
+ if current_dir not in sys.path:
8
+ sys.path.insert(0, current_dir)
9
+
10
+ from backend.services import csv_service, sql_service, llm_service, insight_service
11
+
12
+ def test_csv_ingestion():
13
+ print("\n=== Test 1: CSV Ingestion & Sample Datasets ===")
14
+ try:
15
+ # get_sample_datasets automatically loads files if not loaded
16
+ datasets = csv_service.get_sample_datasets()
17
+ print(f"Discovered and loaded {len(datasets)} datasets.")
18
+ for ds in datasets:
19
+ print(f"- Name: {ds['name']}, Rows: {ds['row_count']}, Columns count: {len(ds['columns'])}")
20
+
21
+ # Verify specific datasets exist in the list
22
+ names = [d["name"] for d in datasets]
23
+ assert "sales" in names, "sales dataset missing"
24
+ assert "employees" in names, "employees dataset missing"
25
+ assert "ecommerce" in names, "ecommerce dataset missing"
26
+
27
+ print("Test 1 passed successfully!")
28
+ return datasets
29
+ except Exception as e:
30
+ print(f"Test 1 failed: {e}")
31
+ sys.exit(1)
32
+
33
+ def test_sql_validation():
34
+ print("\n=== Test 2: SQL Safety Validation ===")
35
+ try:
36
+ # Safe query
37
+ safe_sql = "SELECT employee_id, name, salary FROM data WHERE department = 'Engineering' ORDER BY salary DESC;"
38
+ assert sql_service.validate_sql(safe_sql) == True, "Safe query flagged as dangerous"
39
+ print("[OK] Safe SELECT validated successfully")
40
+
41
+ # Dangerous queries
42
+ unsafe_queries = [
43
+ "DROP TABLE data;",
44
+ "INSERT INTO data (name) VALUES ('Hacker');",
45
+ "UPDATE data SET salary = 999999;",
46
+ "DELETE FROM data;",
47
+ "CREATE TABLE hack (id int);",
48
+ "ALTER TABLE data ADD COLUMN hack TEXT;",
49
+ "EXEC xp_cmdshell 'whoami';"
50
+ ]
51
+
52
+ for q in unsafe_queries:
53
+ assert sql_service.validate_sql(q) == False, f"Dangerous query allowed: {q}"
54
+ print(f"[OK] Blocked dangerous query: {q}")
55
+
56
+ print("Test 2 passed successfully!")
57
+ except Exception as e:
58
+ print(f"Test 2 failed: {e}")
59
+ sys.exit(1)
60
+
61
+ def test_end_to_end_pipeline(datasets):
62
+ print("\n=== Test 3: End-to-End NL to SQL to Insight ===")
63
+ try:
64
+ # Find employees dataset schema
65
+ emp_ds = next(d for d in datasets if d["name"] == "employees")
66
+ schema = {
67
+ "table_name": "data",
68
+ "columns": emp_ds["columns"],
69
+ "row_count": emp_ds["row_count"]
70
+ }
71
+
72
+ question = "What is the average salary and performance score in the Engineering department?"
73
+ print(f"Question: '{question}'")
74
+
75
+ # 1. Generate SQL
76
+ gen_result = llm_service.generate_sql(question, schema)
77
+ generated_sql = gen_result["sql"]
78
+ print(f"Generated SQL: {generated_sql}")
79
+
80
+ # 2. Execute SQL
81
+ exec_result = sql_service.execute_query("employees", generated_sql)
82
+ print(f"Execution Results:\nColumns: {exec_result['columns']}")
83
+ print(f"Rows: {exec_result['rows']}")
84
+ print(f"Time: {exec_result['execution_time_ms']} ms")
85
+
86
+ # 3. Generate Insight
87
+ insight = insight_service.generate_insight(question, generated_sql, exec_result)
88
+ print(f"Generated Insight:\n{insight}")
89
+
90
+ print("Test 3 passed successfully!")
91
+ except Exception as e:
92
+ print(f"Test 3 failed: {e}")
93
+ sys.exit(1)
94
+
95
+ def test_sql_fixing(datasets):
96
+ print("\n=== Test 4: SQL Auto-Fixing on Execution Error ===")
97
+ try:
98
+ emp_ds = next(d for d in datasets if d["name"] == "employees")
99
+ schema = {
100
+ "table_name": "data",
101
+ "columns": emp_ds["columns"],
102
+ "row_count": emp_ds["row_count"]
103
+ }
104
+
105
+ # A bad query that references a column 'departmet' (typo) instead of 'department'
106
+ bad_sql = "SELECT name, salary FROM data WHERE departmet = 'Engineering' LIMIT 5;"
107
+ print(f"Attempting execution of bad SQL: {bad_sql}")
108
+
109
+ try:
110
+ sql_service.execute_query("employees", bad_sql)
111
+ print("Error: Bad SQL was expected to fail execution but succeeded.")
112
+ sys.exit(1)
113
+ except ValueError as err:
114
+ error_message = str(err)
115
+ print(f"Caught expected SQLite error: '{error_message}'")
116
+
117
+ # Now ask the LLM to fix it
118
+ fix_result = llm_service.fix_sql(bad_sql, error_message, schema)
119
+ fixed_sql = fix_result["sql"]
120
+ print(f"Fixed SQL generated by LLM: {fixed_sql}")
121
+
122
+ # Run the fixed SQL
123
+ exec_result = sql_service.execute_query("employees", fixed_sql)
124
+ print(f"Execution of Fixed SQL succeeded! Rows returned: {exec_result['row_count']}")
125
+ print(f"First row: {exec_result['rows'][0] if exec_result['rows'] else 'None'}")
126
+
127
+ print("Test 4 passed successfully!")
128
+ except Exception as e:
129
+ print(f"Test 4 failed: {e}")
130
+ sys.exit(1)
131
+
132
+ if __name__ == "__main__":
133
+ print("Starting tests for Natural Language Data Analyst backend...")
134
+ datasets = test_csv_ingestion()
135
+ test_sql_validation()
136
+ test_end_to_end_pipeline(datasets)
137
+ test_sql_fixing(datasets)
138
+ print("\nAll tests passed successfully!")
sample_data/ecommerce.csv ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ order_id,customer_id,product_name,category,quantity,price,discount,total,order_date,status,payment_method
2
+ O1001,C501,Wireless Mouse,Electronics,2,25,2.5,47.5,2024-05-01,delivered,card
3
+ O1002,C502,Running Shoes,Clothing,1,120,12.0,108.0,2024-05-02,delivered,paypal
4
+ O1003,C503,Coffee Maker,Home & Kitchen,1,85,0.0,85.0,2024-05-03,pending,card
5
+ O1004,C504,Leather Jacket,Clothing,1,250,25.0,225.0,2024-05-03,cancelled,card
6
+ O1005,C505,Desk Lamp,Home & Kitchen,3,30,3.0,87.0,2024-05-04,delivered,cash
7
+ O1006,C506,Bluetooth Speaker,Electronics,1,60,6.0,54.0,2024-05-05,delivered,paypal
8
+ O1007,C507,Yoga Mat,Sports,2,40,0.0,80.0,2024-05-06,pending,card
9
+ O1008,C508,Smart Watch,Electronics,1,199,19.9,179.1,2024-05-07,delivered,card
10
+ O1009,C509,Chef Knife,Home & Kitchen,1,45,4.5,40.5,2024-05-08,delivered,cash
11
+ O1010,C510,Gym Bag,Sports,1,35,0.0,35.0,2024-05-09,cancelled,paypal
12
+ O1011,C511,Backpack,Travel,1,75,7.5,67.5,2024-05-10,delivered,card
13
+ O1012,C512,Keyboard,Electronics,1,50,0.0,50.0,2024-05-10,pending,cash
14
+ O1013,C513,Sunglasses,Clothing,2,20,2.0,38.0,2024-05-11,delivered,card
15
+ O1014,C514,Water Bottle,Sports,4,15,3.0,57.0,2024-05-12,delivered,paypal
16
+ O1015,C515,Notebook Set,Stationery,5,10,0.0,50.0,2024-05-13,delivered,cash
17
+ O1016,C516,Gaming Mouse,Electronics,1,80,8.0,72.0,2024-05-14,delivered,card
18
+ O1017,C517,Winter Coat,Clothing,1,180,18.0,162.0,2024-05-15,cancelled,card
19
+ O1018,C518,Toaster,Home & Kitchen,1,40,0.0,40.0,2024-05-16,delivered,paypal
20
+ O1019,C519,Dumbbells,Sports,2,50,5.0,95.0,2024-05-17,delivered,card
21
+ O1020,C520,Travel Pillow,Travel,2,25,0.0,50.0,2024-05-18,pending,cash
22
+ O1021,C521,USB Hub,Electronics,3,15,1.5,43.5,2024-05-19,delivered,card
23
+ O1022,C522,Denim Jeans,Clothing,1,65,6.5,58.5,2024-05-20,delivered,paypal
24
+ O1023,C523,Blender,Home & Kitchen,1,90,0.0,90.0,2024-05-21,delivered,card
25
+ O1024,C524,Tennis Racket,Sports,1,110,11.0,99.0,2024-05-22,pending,cash
26
+ O1025,C525,Leather Belt,Clothing,2,30,3.0,57.0,2024-05-23,delivered,card
27
+ O1026,C526,External SSD,Electronics,1,130,13.0,117.0,2024-05-24,delivered,paypal
28
+ O1027,C527,Memory Foam Pillow,Home & Kitchen,2,45,4.5,85.5,2024-05-25,cancelled,card
29
+ O1028,C528,Running Shorts,Sports,2,25,0.0,50.0,2024-05-25,delivered,cash
30
+ O1029,C529,Mechanical Pencil,Stationery,10,2,0.2,19.8,2024-05-26,delivered,card
31
+ O1030,C530,HDMI Cable,Electronics,5,10,1.0,49.0,2024-05-27,pending,paypal
32
+ O1031,C531,T-Shirt Pack,Clothing,1,35,3.5,31.5,2024-05-28,delivered,card
33
+ O1032,C532,Air Fryer,Home & Kitchen,1,120,12.0,108.0,2024-05-29,delivered,cash
34
+ O1033,C533,Resistance Bands,Sports,3,12,0.0,36.0,2024-05-30,delivered,paypal
35
+ O1034,C534,Suitcase,Travel,1,150,15.0,135.0,2024-05-31,delivered,card
36
+ O1035,C535,Desk Organizer,Stationery,2,20,2.0,38.0,2024-05-31,pending,cash
sample_data/employees.csv ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ employee_id,name,department,role,salary,hire_date,city,performance_score
2
+ E101,John Doe,Engineering,Software Engineer,85000,2022-03-15,New York,8
3
+ E102,Jane Smith,HR,HR Specialist,60000,2021-06-01,Los Angeles,7
4
+ E103,Michael Green,Sales,Sales Manager,95000,2020-01-10,Chicago,9
5
+ E104,Emily White,Marketing,Marketing Analyst,55000,2023-05-18,San Francisco,6
6
+ E105,David Black,Engineering,Tech Lead,125000,2019-08-24,Seattle,10
7
+ E106,Sarah Brown,HR,HR Director,110000,2018-11-12,New York,9
8
+ E107,James Wilson,Sales,Account Executive,70000,2022-07-01,Los Angeles,8
9
+ E108,Patricia Taylor,Marketing,Marketing Director,105000,2021-02-28,Chicago,8
10
+ E109,Robert Miller,Engineering,Software Engineer,90000,2023-01-15,San Francisco,7
11
+ E110,Linda Davis,Sales,Sales Associate,48000,2023-11-01,Seattle,5
12
+ E111,William Garcia,Engineering,QA Engineer,75000,2022-10-10,New York,8
13
+ E112,Elizabeth Martinez,HR,Recruiter,65000,2023-04-01,Los Angeles,7
14
+ E113,Richard Robinson,Sales,Sales Director,130000,2017-05-15,Chicago,9
15
+ E114,Barbara Clark,Marketing,SEO Specialist,50000,2024-01-10,San Francisco,6
16
+ E115,Joseph Rodriguez,Engineering,DevOps Engineer,98000,2021-09-01,Seattle,8
17
+ E116,Susan Lewis,Engineering,Backend Engineer,105000,2022-04-20,New York,9
18
+ E117,Thomas Lee,Sales,Account Executive,72000,2023-06-15,Los Angeles,7
19
+ E118,Jessica Walker,Marketing,Content Strategist,58000,2023-08-12,Chicago,8
20
+ E119,Charles Hall,Engineering,Frontend Engineer,82000,2023-02-28,San Francisco,7
21
+ E120,Nancy Allen,HR,HR Coordinator,45000,2024-03-01,Seattle,4
22
+ E121,Daniel Young,Engineering,Data Scientist,115000,2020-11-15,New York,9
23
+ E122,Sandra Hernandez,Sales,Sales Associate,46000,2024-02-15,Los Angeles,6
24
+ E123,Matthew King,Marketing,PR Manager,78000,2021-12-01,Chicago,8
25
+ E124,Ashley Wright,Engineering,Engineering Manager,145000,2018-04-01,San Francisco,10
26
+ E125,Mark Lopez,Sales,Sales Associate,49000,2024-05-01,Seattle,6
sample_data/sales.csv ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ date,product,category,region,units_sold,unit_price,revenue,salesperson
2
+ 2024-01-15,Laptop,Electronics,North,5,1200,6000,Alice Smith
3
+ 2024-01-18,T-Shirt,Clothing,East,20,25,500,Bob Johnson
4
+ 2024-01-22,Apple,Food,West,100,1.5,150,Charlie Brown
5
+ 2024-02-05,Smartphone,Electronics,South,8,800,6400,Diana Prince
6
+ 2024-02-12,Jeans,Clothing,North,15,60,900,Alice Smith
7
+ 2024-02-20,Banana,Food,East,150,1,150,Bob Johnson
8
+ 2024-03-02,Headphones,Electronics,West,12,150,1800,Charlie Brown
9
+ 2024-03-15,Jacket,Clothing,South,10,120,1200,Diana Prince
10
+ 2024-03-22,Orange,Food,North,80,2,160,Alice Smith
11
+ 2024-04-05,Tablet,Electronics,East,6,450,2700,Bob Johnson
12
+ 2024-04-12,Sneakers,Clothing,West,8,90,720,Charlie Brown
13
+ 2024-04-20,Milk,Food,South,50,3,150,Diana Prince
14
+ 2024-05-02,Smartwatch,Electronics,North,10,250,2500,Alice Smith
15
+ 2024-05-14,Socks,Clothing,East,50,5,250,Bob Johnson
16
+ 2024-05-28,Bread,Food,West,60,2.5,150,Charlie Brown
17
+ 2024-06-05,Monitor,Electronics,South,4,300,1200,Diana Prince
18
+ 2024-06-12,Dress,Clothing,North,7,80,560,Alice Smith
19
+ 2024-06-25,Cheese,Food,East,40,5,200,Bob Johnson
20
+ 2024-07-02,Keyboard,Electronics,West,15,50,750,Charlie Brown
21
+ 2024-07-15,Sweater,Clothing,South,6,70,420,Diana Prince
22
+ 2024-07-22,Yogurt,Food,North,90,1.8,162,Alice Smith
23
+ 2024-08-05,Mouse,Electronics,East,25,30,750,Bob Johnson
24
+ 2024-08-12,Hat,Clothing,West,30,15,450,Charlie Brown
25
+ 2024-08-20,Beef,Food,South,15,15,225,Diana Prince
26
+ 2024-09-02,Printer,Electronics,North,3,200,600,Alice Smith
27
+ 2024-09-14,Belt,Clothing,East,22,20,440,Bob Johnson
28
+ 2024-09-28,Chicken,Food,West,20,10,200,Charlie Brown
29
+ 2024-10-05,Camera,Electronics,South,2,600,1200,Diana Prince
30
+ 2024-10-12,Scarf,Clothing,North,18,18,324,Alice Smith
31
+ 2024-10-25,Rice,Food,East,100,2.2,220,Bob Johnson
sample_data/schema/ecommerce.db ADDED
Binary file (24.6 kB). View file
 
sample_data/schema/ecommerce_schema.sql ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ CREATE TABLE customers (
2
+ customer_id INTEGER PRIMARY KEY,
3
+ name TEXT, email TEXT, city TEXT, country TEXT, signup_date TEXT
4
+ );
5
+ CREATE TABLE products (
6
+ product_id INTEGER PRIMARY KEY,
7
+ name TEXT, category TEXT, price REAL, stock_quantity INTEGER
8
+ );
9
+ CREATE TABLE orders (
10
+ order_id INTEGER PRIMARY KEY,
11
+ customer_id INTEGER, order_date TEXT, status TEXT, payment_method TEXT,
12
+ FOREIGN KEY (customer_id) REFERENCES customers(customer_id)
13
+ );
14
+ CREATE TABLE order_items (
15
+ item_id INTEGER PRIMARY KEY,
16
+ order_id INTEGER, product_id INTEGER, quantity INTEGER, unit_price REAL,
17
+ FOREIGN KEY (order_id) REFERENCES orders(order_id),
18
+ FOREIGN KEY (product_id) REFERENCES products(product_id)
19
+ );
20
+ CREATE TABLE reviews (
21
+ review_id INTEGER PRIMARY KEY,
22
+ customer_id INTEGER, product_id INTEGER, rating INTEGER, review_date TEXT,
23
+ FOREIGN KEY (customer_id) REFERENCES customers(customer_id),
24
+ FOREIGN KEY (product_id) REFERENCES products(product_id)
25
+ );
sample_data/schema/generate_ecommerce_db.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sqlite3
3
+ import random
4
+ from datetime import datetime, timedelta
5
+
6
+ def main():
7
+ print("[generate_ecommerce_db] Generating ecommerce.db...")
8
+
9
+ current_dir = os.path.dirname(os.path.abspath(__file__))
10
+ db_path = os.path.join(current_dir, "ecommerce.db")
11
+ schema_path = os.path.join(current_dir, "ecommerce_schema.sql")
12
+
13
+ # Remove existing db if any
14
+ if os.path.exists(db_path):
15
+ os.remove(db_path)
16
+
17
+ conn = sqlite3.connect(db_path)
18
+ cursor = conn.cursor()
19
+
20
+ # Read and execute schema SQL
21
+ with open(schema_path, "r") as f:
22
+ schema_sql = f.read()
23
+
24
+ cursor.executescript(schema_sql)
25
+ conn.commit()
26
+
27
+ # 1. Generate 20 customers
28
+ customer_names = [
29
+ "Alice Smith", "Bob Johnson", "Charlie Brown", "Diana Prince",
30
+ "Evan Wright", "Fiona Gallagher", "George Costanza", "Hannah Abbott",
31
+ "Ian Malcolm", "Julia Roberts", "Kevin Bacon", "Laura Croft",
32
+ "Michael Scott", "Nancy Drew", "Oscar Martinez", "Pamela Beesly",
33
+ "Quentin Tarantino", "Rachel Green", "Steve Rogers", "Tony Stark"
34
+ ]
35
+ cities = ["New York", "Los Angeles", "Chicago", "London", "Paris", "Tokyo", "Sydney", "Toronto", "Berlin", "Mumbai"]
36
+ countries = ["USA", "USA", "USA", "UK", "France", "Japan", "Australia", "Canada", "Germany", "India"]
37
+
38
+ # Let's seed random to be deterministic
39
+ random.seed(42)
40
+
41
+ customers_data = []
42
+ base_date = datetime(2025, 1, 1)
43
+ for i, name in enumerate(customer_names):
44
+ customer_id = i + 1
45
+ email = name.lower().replace(" ", ".") + "@example.com"
46
+ city_idx = random.randint(0, len(cities) - 1)
47
+ city = cities[city_idx]
48
+ country = countries[city_idx] # map city to country
49
+ signup_days = random.randint(0, 360)
50
+ signup_date = (base_date + timedelta(days=signup_days)).strftime("%Y-%m-%d")
51
+ customers_data.append((customer_id, name, email, city, country, signup_date))
52
+
53
+ cursor.executemany(
54
+ "INSERT INTO customers (customer_id, name, email, city, country, signup_date) VALUES (?, ?, ?, ?, ?, ?)",
55
+ customers_data
56
+ )
57
+
58
+ # 2. Generate 15 products (10-500 range prices)
59
+ products_source = [
60
+ ("Wireless Earbuds", "Electronics", 79.99),
61
+ ("Smart Watch", "Electronics", 199.99),
62
+ ("Sony Headset", "Electronics", 149.99),
63
+ ("Mechanical Keyboard", "Electronics", 129.99),
64
+ ("Nike Sneakers", "Clothing", 89.99),
65
+ ("Levi Jeans", "Clothing", 59.99),
66
+ ("Woolen Sweater", "Clothing", 45.00),
67
+ ("Leather Jacket", "Clothing", 249.99),
68
+ ("Dune Novel", "Books", 14.99),
69
+ ("Python Guide", "Books", 29.99),
70
+ ("Cookware Set", "Home", 189.99),
71
+ ("Desk Lamp", "Home", 35.00),
72
+ ("Office Chair", "Home", 179.99),
73
+ ("Coffee Maker", "Home", 99.99),
74
+ ("Electric Kettle", "Home", 24.99)
75
+ ]
76
+
77
+ products_data = []
78
+ for i, (name, category, price) in enumerate(products_source):
79
+ product_id = i + 1
80
+ stock = random.randint(10, 150)
81
+ products_data.append((product_id, name, category, price, stock))
82
+
83
+ cursor.executemany(
84
+ "INSERT INTO products (product_id, name, category, price, stock_quantity) VALUES (?, ?, ?, ?, ?)",
85
+ products_data
86
+ )
87
+
88
+ # 3. Generate 40 orders
89
+ statuses = ["Delivered", "Delivered", "Delivered", "Pending", "Cancelled"]
90
+ payments = ["Credit Card", "PayPal", "Cash on Delivery"]
91
+ orders_data = []
92
+ base_order_date = datetime(2026, 1, 1)
93
+
94
+ for order_id in range(1, 41):
95
+ customer_id = random.randint(1, 20)
96
+ order_days = random.randint(0, 160)
97
+ order_date = (base_order_date + timedelta(days=order_days)).strftime("%Y-%m-%d")
98
+ status = random.choice(statuses)
99
+ payment_method = random.choice(payments)
100
+ orders_data.append((order_id, customer_id, order_date, status, payment_method))
101
+
102
+ cursor.executemany(
103
+ "INSERT INTO orders (order_id, customer_id, order_date, status, payment_method) VALUES (?, ?, ?, ?, ?)",
104
+ orders_data
105
+ )
106
+
107
+ # 4. Generate 80 order_items
108
+ item_id = 1
109
+ order_items_data = []
110
+
111
+ # Ensure every order has at least 1 item
112
+ for order_id in range(1, 41):
113
+ num_items = random.randint(1, 3)
114
+ chosen_products = random.sample(range(1, 16), num_items)
115
+ for prod_id in chosen_products:
116
+ prod_price = products_data[prod_id - 1][3]
117
+ qty = random.randint(1, 4)
118
+ order_items_data.append((item_id, order_id, prod_id, qty, prod_price))
119
+ item_id += 1
120
+ if len(order_items_data) == 80:
121
+ break
122
+ if len(order_items_data) == 80:
123
+ break
124
+
125
+ # If we still need to reach 80, add more to random orders
126
+ while len(order_items_data) < 80:
127
+ ord_id = random.randint(1, 40)
128
+ prod_id = random.randint(1, 15)
129
+ prod_price = products_data[prod_id - 1][3]
130
+ qty = random.randint(1, 4)
131
+ # Check if already in order to keep unique if desired, but not strictly database constrained except PK
132
+ order_items_data.append((item_id, ord_id, prod_id, qty, prod_price))
133
+ item_id += 1
134
+
135
+ cursor.executemany(
136
+ "INSERT INTO order_items (item_id, order_id, product_id, quantity, unit_price) VALUES (?, ?, ?, ?, ?)",
137
+ order_items_data
138
+ )
139
+
140
+ # 5. Generate 30 reviews
141
+ reviews_data = []
142
+ base_review_date = datetime(2026, 1, 15)
143
+
144
+ for review_id in range(1, 31):
145
+ customer_id = random.randint(1, 20)
146
+ product_id = random.randint(1, 15)
147
+ rating = random.randint(1, 5)
148
+ review_days = random.randint(10, 150)
149
+ review_date = (base_review_date + timedelta(days=review_days)).strftime("%Y-%m-%d")
150
+ reviews_data.append((review_id, customer_id, product_id, rating, review_date))
151
+
152
+ cursor.executemany(
153
+ "INSERT INTO reviews (review_id, customer_id, product_id, rating, review_date) VALUES (?, ?, ?, ?, ?)",
154
+ reviews_data
155
+ )
156
+
157
+ conn.commit()
158
+ conn.close()
159
+ print("[generate_ecommerce_db] ecommerce.db created successfully!")
160
+
161
+ if __name__ == "__main__":
162
+ main()