Soumik Bose commited on
Commit
7ccd501
·
0 Parent(s):

Initial commit

Browse files
Dockerfile ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use the official Python 3.11 slim image
2
+ FROM python:3.11-slim
3
+
4
+ # Install curl for keep-alive script and clean up
5
+ RUN apt-get update && \
6
+ apt-get install -y curl && \
7
+ rm -rf /var/lib/apt/lists/*
8
+
9
+ # Set the working directory inside the container
10
+ WORKDIR /app
11
+
12
+ # Create all required directories with proper permissions upfront
13
+ RUN mkdir -p /app/generated_outputs && \
14
+ mkdir -p /app/generated_charts && \
15
+ mkdir -p /app/cache && \
16
+ chmod -R 777 /app/generated_outputs && \
17
+ chmod -R 777 /app/generated_charts && \
18
+ chmod -R 777 /app/cache
19
+
20
+ # Create log files with proper permissions
21
+ RUN touch /app/pandasai.log && \
22
+ touch /app/api_key_rotation.log && \
23
+ chmod 666 /app/pandasai.log && \
24
+ chmod 666 /app/api_key_rotation.log
25
+
26
+ # Set environment variables
27
+ ENV MPLCONFIGDIR=/app/cache
28
+
29
+ # Copy the requirements file first
30
+ COPY requirements.txt .
31
+
32
+ # Install dependencies
33
+ RUN pip install --no-cache-dir -r requirements.txt
34
+
35
+ # Copy the rest of the application code
36
+ COPY . .
37
+
38
+ # Ensure the user has write permissions to all directories
39
+ RUN chown -R 1000:1000 /app && \
40
+ chmod -R 777 /app
41
+
42
+ # Expose port 7860 (required by Hugging Face Spaces)
43
+ EXPOSE 7860
44
+
45
+ # Keep-alive command (pings every 5 minutes) + start Uvicorn
46
+ CMD bash -c "while true; do curl -s https://soumik555-fastapi.hf.space/ping >/dev/null && sleep 300; done & uvicorn controller:app --host 0.0.0.0 --port 7860"
__pycache__/controller.cpython-311.pyc ADDED
Binary file (32.5 kB). View file
 
__pycache__/csv_analysis_service.cpython-311.pyc ADDED
Binary file (8.65 kB). View file
 
__pycache__/csv_chart_service.cpython-311.pyc ADDED
Binary file (3.19 kB). View file
 
__pycache__/csv_metadata_service.cpython-311.pyc ADDED
Binary file (16.4 kB). View file
 
__pycache__/mongo_service.cpython-311.pyc ADDED
Binary file (7.94 kB). View file
 
__pycache__/mysql_service.cpython-311.pyc ADDED
Binary file (7.31 kB). View file
 
__pycache__/pydantic_csv_analysis_model.cpython-311.pyc ADDED
Binary file (1.12 kB). View file
 
__pycache__/pydantic_csv_charts_model.cpython-311.pyc ADDED
Binary file (1.71 kB). View file
 
__pycache__/pydantic_mongo_executor_model.cpython-311.pyc ADDED
Binary file (1.88 kB). View file
 
__pycache__/pydantic_mysql_model.cpython-311.pyc ADDED
Binary file (1.63 kB). View file
 
__pycache__/report_service.cpython-311.pyc ADDED
Binary file (11.8 kB). View file
 
__pycache__/supabase_service.cpython-311.pyc ADDED
Binary file (5.4 kB). View file
 
controller.py ADDED
@@ -0,0 +1,474 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ from contextlib import asynccontextmanager
3
+ import logging
4
+ import time
5
+ import uuid
6
+ import os
7
+ import re
8
+ import asyncio # Added for parallel batching
9
+ import multiprocessing # Added for worker calculation
10
+ from typing import List, Optional, Dict, Any, TypeVar, Generic # Added Generic/TypeVar
11
+ from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
12
+
13
+ # --- FastAPI & Core ---
14
+ from fastapi import FastAPI, HTTPException, Depends
15
+ from fastapi.encoders import jsonable_encoder
16
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
17
+ from fastapi.middleware.cors import CORSMiddleware
18
+ from starlette.concurrency import run_in_threadpool
19
+ from anyio import to_thread # Added for AnyIO 4.x concurrency tuning
20
+ from dotenv import load_dotenv
21
+ from pydantic import BaseModel
22
+
23
+ # --- Database Drivers ---
24
+ from bson import ObjectId
25
+ import mysql.connector
26
+ import psycopg2
27
+ from psycopg2.extras import RealDictCursor
28
+
29
+ # --- Existing Services ---
30
+ from csv_analysis_service import execute_analysis_logic
31
+ from csv_chart_service import execute_python_code
32
+ from csv_metadata_service import CsvDataRequest, CsvInfoRequest, CsvInfoResponse, PythonExecutionRequest, PythonExecutionResponse, execute_python_logic, get_csv_basic_info, get_robust_csv_rows
33
+ from mongo_service import execute_mongo_operation, parse_query_input
34
+ from pydantic_csv_analysis_model import AnalysisRequest, AnalysisResponse
35
+ from pydantic_csv_charts_model import ChartExecutionPayload, ChartExecutionResponse
36
+ from pydantic_mongo_executor_model import ExecutorPayload, ExecutorResponse
37
+ from report_service import FileBoxProps, ReportRequest, execute_report_generation
38
+ from supabase_service import upload_bytes_to_supabase
39
+
40
+ # --- Configuration & Setup ---
41
+ load_dotenv()
42
+
43
+ logging.basicConfig(
44
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
45
+ level=logging.INFO
46
+ )
47
+ logger = logging.getLogger("API_Controller")
48
+
49
+ @asynccontextmanager
50
+ async def lifespan(app: FastAPI):
51
+ # Startup logic
52
+ to_thread.current_default_thread_limiter().total_tokens = 2000
53
+ logger.info("Worker Process Started: Thread pool capacity set to 2000.")
54
+ yield
55
+ # Shutdown logic (if any) goes here
56
+
57
+ app = FastAPI(
58
+ title="Unified Data Executor API (Mongo, SQL, CSV)",
59
+ lifespan=lifespan
60
+ )
61
+
62
+ # ==============================================================================
63
+ # HIGH-CONCURRENCY BATCH MODELS & STARTUP
64
+ # ==============================================================================
65
+ T = TypeVar("T")
66
+
67
+ class BatchRequest(BaseModel, Generic[T]):
68
+ requests: List[T]
69
+
70
+ class BatchResponse(BaseModel, Generic[T]):
71
+ responses: List[T]
72
+
73
+ # --- Directory Setup ---
74
+ CHART_DIR = "generated_charts"
75
+ os.makedirs(CHART_DIR, exist_ok=True)
76
+
77
+ # --- CORS ---
78
+ origins_env = os.getenv("ALLOWED_ORIGINS", "*")
79
+ ORIGINS = [origin.strip() for origin in origins_env.split(",")]
80
+
81
+ app.add_middleware(
82
+ CORSMiddleware,
83
+ allow_origins=ORIGINS,
84
+ allow_credentials=True,
85
+ allow_methods=["*"],
86
+ allow_headers=["*"],
87
+ )
88
+
89
+ # --- Security ---
90
+ security = HTTPBearer()
91
+ API_SECRET_TOKEN = os.getenv("API_BEARER_TOKEN")
92
+
93
+ if not API_SECRET_TOKEN:
94
+ logger.warning("WARNING: API_BEARER_TOKEN not set in .env file! Security is compromised.")
95
+
96
+ async def validate_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
97
+ if credentials.credentials != API_SECRET_TOKEN:
98
+ raise HTTPException(status_code=403, detail="Invalid Authentication Token")
99
+ return credentials.credentials
100
+
101
+
102
+ # ==============================================================================
103
+ # PYDANTIC MODELS
104
+ # ==============================================================================
105
+
106
+ # --- MySQL Models ---
107
+ class SqlQueryRequest(BaseModel):
108
+ database_url: str
109
+ sql_query: str
110
+ limit_rows: Optional[int] = 20
111
+ limited: bool = False
112
+
113
+ class SqlQueryResponse(BaseModel):
114
+ success: bool
115
+ results: Optional[List[Dict[str, Any]]] = None
116
+ columns: Optional[List[str]] = None
117
+ rowCount: Optional[int] = 0
118
+ executionTime: Optional[float] = 0.0
119
+ error: Optional[str] = None
120
+ request_id: str
121
+ is_aggregate: bool = False
122
+ limited: bool = False
123
+ message: Optional[str] = None
124
+
125
+ # --- PostgreSQL Models ---
126
+ class PgQueryRequest(BaseModel):
127
+ database_url: str
128
+ sql_query: str
129
+ limit_rows: Optional[int] = 20
130
+ limited: bool = False
131
+
132
+ class PgQueryResponse(BaseModel):
133
+ success: bool
134
+ results: Optional[List[Dict[str, Any]]] = None
135
+ columns: Optional[List[str]] = None
136
+ rowCount: Optional[int] = 0
137
+ executionTime: Optional[float] = 0.0
138
+ error: Optional[str] = None
139
+ request_id: str
140
+ is_aggregate: bool = False
141
+ limited: bool = False
142
+ message: Optional[str] = None
143
+
144
+
145
+ # ==============================================================================
146
+ # SHARED HELPER FUNCTIONS
147
+ # ==============================================================================
148
+
149
+ def is_aggregate_query(query: str) -> bool:
150
+ """Checks for aggregate keywords."""
151
+ query_lower = query.lower()
152
+ aggregate_patterns = [
153
+ r'\bcount\s*\(', r'\bsum\s*\(', r'\bavg\s*\(',
154
+ r'\bmin\s*\(', r'\bmax\s*\(', r'\bgroup\s+by\b',
155
+ r'\bdistinct\b', r'\bhaving\b'
156
+ ]
157
+ for pattern in aggregate_patterns:
158
+ if re.search(pattern, query_lower):
159
+ return True
160
+ return False
161
+
162
+ # ==============================================================================
163
+ # MYSQL LOGIC
164
+ # ==============================================================================
165
+
166
+ def normalize_mysql_uri(uri: str) -> str:
167
+ try:
168
+ parsed_uri = urlparse(uri)
169
+ query_params = parse_qs(parsed_uri.query)
170
+ query_params.pop('ssl-mode', None)
171
+ new_query = urlencode(query_params, doseq=True)
172
+ parsed_uri = parsed_uri._replace(query=new_query)
173
+ return urlunparse(parsed_uri)
174
+ except Exception:
175
+ return uri
176
+
177
+ def _run_mysql_synchronously(db_url: str, sql_query: str, max_rows: int = 20, limited: bool = False) -> dict:
178
+ start_time = time.time()
179
+ connection = None
180
+ cursor = None
181
+ response = {"success": False, "results": None, "columns": None, "rowCount": 0, "executionTime": 0.0, "error": None, "is_aggregate": False, "limited": False, "message": ""}
182
+
183
+ try:
184
+ parsed = urlparse(db_url)
185
+ db_config = {
186
+ "user": parsed.username, "password": parsed.password,
187
+ "host": parsed.hostname, "port": parsed.port or 3306,
188
+ "database": parsed.path.lstrip("/"), "connect_timeout": 5
189
+ }
190
+ connection = mysql.connector.connect(**db_config)
191
+ cursor = connection.cursor(dictionary=True)
192
+
193
+ clean_query = sql_query.strip()
194
+ query_lower = clean_query.lower()
195
+
196
+ if not query_lower.startswith("select"):
197
+ cursor.execute(clean_query)
198
+ connection.commit()
199
+ response.update({"success": True, "message": "Query executed successfully (Non-SELECT)."})
200
+ return response
201
+
202
+ if not limited:
203
+ cursor.execute(clean_query)
204
+ results = cursor.fetchall()
205
+ response["message"] = f"Raw query executed. Returned {len(results)} row(s)."
206
+ response["limited"] = False
207
+ response["is_aggregate"] = is_aggregate_query(clean_query)
208
+ else:
209
+ if is_aggregate_query(clean_query):
210
+ cursor.execute(clean_query)
211
+ results = cursor.fetchall()
212
+ response["message"] = f"Aggregate query completed. Returned {len(results)} row(s)."
213
+ response["is_aggregate"] = True
214
+ else:
215
+ final_query = clean_query.rstrip(';').strip()
216
+ if not re.search(r'\blimit\s+\d+', query_lower):
217
+ final_query = f"{final_query} LIMIT {max_rows}"
218
+
219
+ cursor.execute(final_query)
220
+ results = cursor.fetchall()
221
+
222
+ is_limited_result = (len(results) == max_rows)
223
+ response["message"] = f"Showing first {max_rows} rows only." if is_limited_result else f"Returned {len(results)} rows."
224
+ response["limited"] = is_limited_result
225
+ response["is_aggregate"] = False
226
+
227
+ columns = [col[0] for col in cursor.description] if cursor.description else []
228
+ response.update({"success": True, "results": jsonable_encoder(results), "columns": columns, "rowCount": len(results), "executionTime": time.time() - start_time})
229
+ return response
230
+
231
+ except Exception as e:
232
+ response["error"] = str(e)
233
+ return response
234
+ finally:
235
+ if cursor: cursor.close()
236
+ if connection and connection.is_connected(): connection.close()
237
+
238
+ # ==============================================================================
239
+ # POSTGRES LOGIC
240
+ # ==============================================================================
241
+
242
+ def normalize_postgres_uri(uri: str) -> str:
243
+ try:
244
+ parsed_uri = urlparse(uri)
245
+ if parsed_uri.scheme == 'postgres':
246
+ parsed_uri = parsed_uri._replace(scheme='postgresql')
247
+ return urlunparse(parsed_uri)
248
+ except Exception:
249
+ return uri
250
+
251
+ def _run_postgres_synchronously(db_url: str, sql_query: str, max_rows: int = 20, limited: bool = False) -> dict:
252
+ start_time = time.time()
253
+ connection = None
254
+ cursor = None
255
+ response = {"success": False, "results": None, "columns": None, "rowCount": 0, "executionTime": 0.0, "error": None, "is_aggregate": False, "limited": False, "message": ""}
256
+ try:
257
+ parsed = urlparse(db_url)
258
+ qs = parse_qs(parsed.query)
259
+ sslmode = qs.get('sslmode', ['require'])[0] if 'sslmode' in qs else 'prefer'
260
+ db_config = {"host": parsed.hostname, "port": parsed.port or 5432, "database": parsed.path.lstrip("/"), "user": parsed.username, "password": parsed.password, "sslmode": sslmode, "connect_timeout": 5}
261
+ connection = psycopg2.connect(**db_config)
262
+ cursor = connection.cursor(cursor_factory=RealDictCursor)
263
+ clean_query = sql_query.strip()
264
+ query_lower = clean_query.lower()
265
+ if not query_lower.startswith(("select", "show", "explain", "with")):
266
+ cursor.execute(clean_query)
267
+ connection.commit()
268
+ response.update({"success": True, "message": "Query executed successfully (Non-SELECT)."})
269
+ return response
270
+ if not limited:
271
+ cursor.execute(clean_query)
272
+ results = cursor.fetchall()
273
+ response["message"] = f"Raw query executed. Returned {len(results)} row(s)."
274
+ response["limited"] = False
275
+ response["is_aggregate"] = is_aggregate_query(clean_query)
276
+ else:
277
+ if is_aggregate_query(clean_query):
278
+ cursor.execute(clean_query)
279
+ results = cursor.fetchall()
280
+ response["message"] = f"Aggregate query completed. Returned {len(results)} row(s)."
281
+ response["is_aggregate"] = True
282
+ response["limited"] = False
283
+ else:
284
+ final_query = clean_query.rstrip(';').strip()
285
+ if not re.search(r'\blimit\s+\d+', query_lower):
286
+ final_query = f"{final_query} LIMIT {max_rows}"
287
+ cursor.execute(final_query)
288
+ results = cursor.fetchall()
289
+ is_limited_result = (len(results) == max_rows)
290
+ response["message"] = f"Showing first {max_rows} rows only." if is_limited_result else f"Returned {len(results)} rows."
291
+ response["limited"] = is_limited_result
292
+ response["is_aggregate"] = False
293
+ columns = [desc[0] for desc in cursor.description] if cursor.description else []
294
+ clean_results = jsonable_encoder(results, custom_encoder={uuid.UUID: str, ObjectId: str})
295
+ response.update({"success": True, "results": clean_results, "columns": columns, "rowCount": len(results), "executionTime": time.time() - start_time})
296
+ return response
297
+ except Exception as e:
298
+ if connection: connection.rollback()
299
+ response["error"] = str(e)
300
+ return response
301
+ finally:
302
+ if cursor: cursor.close()
303
+ if connection: connection.close()
304
+
305
+
306
+ # ==============================================================================
307
+ # API ROUTES
308
+ # ==============================================================================
309
+
310
+ @app.post("/api/execute_mongo", response_model=ExecutorResponse)
311
+ async def execute_mongo_endpoint(payload: ExecutorPayload, token: str = Depends(validate_token)):
312
+ request_id = str(uuid.uuid4())[:8]
313
+ start_time = time.time()
314
+ try:
315
+ parsed_query = parse_query_input(payload.generated_query)
316
+ result_data = await run_in_threadpool(execute_mongo_operation, mongo_uri=payload.mongo_uri, db_name=payload.db_name, collection_name=payload.collection_name, query=parsed_query, limited=payload.limited, limit_rows=payload.limit_rows)
317
+ return ExecutorResponse(status="success", count=len(result_data), data=jsonable_encoder(result_data, custom_encoder={ObjectId: str}), duration_seconds=round(time.time() - start_time, 4), request_id=request_id)
318
+ except Exception as e:
319
+ raise HTTPException(status_code=500, detail=str(e))
320
+
321
+ @app.post("/api/execute_chart", response_model=ChartExecutionResponse)
322
+ async def execute_chart_endpoint(payload: ChartExecutionPayload, token: str = Depends(validate_token)):
323
+ request_id = str(uuid.uuid4())[:8]
324
+ try:
325
+ # 1. Execute Code
326
+ image_bytes, error_msg, logs = await run_in_threadpool(execute_python_code, code=payload.code, csv_url=payload.csv_url)
327
+
328
+ if error_msg:
329
+ return ChartExecutionResponse(status="error", error=error_msg, output_log=logs, request_id=request_id)
330
+
331
+ # 2. Handle Output Format
332
+ if payload.return_base64:
333
+ # OPTION A: Return Base64 (No Supabase Upload)
334
+ base64_str = base64.b64encode(image_bytes).decode('utf-8')
335
+ return ChartExecutionResponse(
336
+ status="success",
337
+ base64_image=base64_str,
338
+ output_log=logs,
339
+ request_id=request_id
340
+ )
341
+ else:
342
+ # OPTION B: Upload to Supabase (Standard behavior)
343
+ unique_name = f"{uuid.uuid4()}.png"
344
+ public_url = await run_in_threadpool(upload_bytes_to_supabase, image_bytes=image_bytes, file_name=unique_name, chat_id=payload.chat_id)
345
+ return ChartExecutionResponse(
346
+ status="success",
347
+ image_url=public_url,
348
+ output_log=logs,
349
+ request_id=request_id
350
+ )
351
+
352
+ except Exception as e:
353
+ raise HTTPException(status_code=500, detail=str(e))
354
+
355
+ @app.post("/api/execute_sql_query", response_model=SqlQueryResponse)
356
+ async def execute_mysql_endpoint(query: SqlQueryRequest, token: str = Depends(validate_token)):
357
+ request_id = str(uuid.uuid4())[:8]
358
+ try:
359
+ normalized_url = normalize_mysql_uri(query.database_url)
360
+ limit_val = query.limit_rows if query.limit_rows is not None else 20
361
+ result_dict = await run_in_threadpool(_run_mysql_synchronously, db_url=normalized_url, sql_query=query.sql_query, max_rows=limit_val, limited=query.limited)
362
+ result_dict["request_id"] = request_id
363
+ return SqlQueryResponse(**result_dict)
364
+ except Exception as e:
365
+ raise HTTPException(status_code=500, detail={"success": False, "error": str(e), "request_id": request_id})
366
+
367
+ @app.post("/api/execute_postgres_query", response_model=PgQueryResponse)
368
+ async def execute_postgres_endpoint(query: PgQueryRequest, token: str = Depends(validate_token)):
369
+ request_id = str(uuid.uuid4())[:8]
370
+ try:
371
+ clean_url = normalize_postgres_uri(query.database_url)
372
+ limit_val = query.limit_rows if query.limit_rows is not None else 20
373
+ result_dict = await run_in_threadpool(_run_postgres_synchronously, db_url=clean_url, sql_query=query.sql_query, max_rows=limit_val, limited=query.limited)
374
+ result_dict["request_id"] = request_id
375
+ return PgQueryResponse(**result_dict)
376
+ except Exception as e:
377
+ raise HTTPException(status_code=500, detail={"success": False, "error": str(e), "request_id": request_id})
378
+
379
+ @app.post("/api/execute_csv_analysis", response_model=AnalysisResponse)
380
+ async def execute_analysis_endpoint(payload: AnalysisRequest, token: str = Depends(validate_token)):
381
+ request_id = str(uuid.uuid4())[:8]
382
+ try:
383
+ result = await run_in_threadpool(execute_analysis_logic, code=payload.code, csv_url=payload.csv_url)
384
+ return AnalysisResponse(success=result["success"], output_log=result["output_log"], results=result["results"], error=result["error"], request_id=request_id)
385
+ except Exception as e:
386
+ raise HTTPException(status_code=500, detail=str(e))
387
+
388
+ @app.post("/api/generate_report", response_model=FileBoxProps)
389
+ async def generate_report_endpoint(payload: ReportRequest, token: str = Depends(validate_token)):
390
+ try:
391
+ result = await execute_report_generation(code=payload.code, csv_url=payload.csv_url, chat_id=payload.chat_id)
392
+ return result
393
+ except Exception as e:
394
+ raise HTTPException(status_code=500, detail=str(e))
395
+
396
+ @app.post("/api/get_csv_info", response_model=CsvInfoResponse)
397
+ async def get_csv_info_endpoint(payload: CsvInfoRequest, token: str = Depends(validate_token)):
398
+ request_id = str(uuid.uuid4())[:8]
399
+ start_time = time.time()
400
+ try:
401
+ info_result = await run_in_threadpool(get_csv_basic_info, csv_path=payload.csv_url)
402
+ if "error" in info_result:
403
+ return CsvInfoResponse(success=False, error=info_result["error"], request_id=request_id, duration=time.time() - start_time)
404
+ return CsvInfoResponse(success=True, data=info_result, request_id=request_id, duration=time.time() - start_time)
405
+ except Exception as e:
406
+ raise HTTPException(status_code=500, detail={"success": False, "error": str(e), "request_id": request_id})
407
+
408
+ @app.post("/api/csv_data")
409
+ async def get_csv_data_endpoint(payload: CsvDataRequest, token: str = Depends(validate_token)):
410
+ try:
411
+ result = await run_in_threadpool(get_robust_csv_rows, csv_url=payload.csv_url)
412
+ if isinstance(result, dict) and "error" in result: raise HTTPException(status_code=400, detail=result["error"])
413
+ return result
414
+ except Exception as e:
415
+ raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
416
+
417
+ @app.post("/api/execute_python", response_model=PythonExecutionResponse)
418
+ async def execute_python_endpoint(payload: PythonExecutionRequest, token: str = Depends(validate_token)):
419
+ request_id = str(uuid.uuid4())[:8]
420
+ try:
421
+ execution_result = await run_in_threadpool(execute_python_logic, code=payload.code, custom_context=payload.context)
422
+ return PythonExecutionResponse(success=execution_result['error'] is None, output=execution_result['output'], result=jsonable_encoder(execution_result['result']), isStructured=execution_result['isStructured'], error=execution_result['error'], request_id=request_id)
423
+ except Exception as e:
424
+ raise HTTPException(status_code=500, detail={"success": False, "error": str(e), "request_id": request_id})
425
+
426
+ # ==============================================================================
427
+ # NEW: BATCH HANDLER LOGIC (Scaling for 1000+ Requests)
428
+ # ==============================================================================
429
+
430
+ async def batch_parallel_handler(func, requests: List[Any], token: str):
431
+ """
432
+ Executes multiple requests in parallel within a single worker process
433
+ using asyncio.gather, utilizing the high-token thread pool.
434
+ """
435
+ tasks = [func(req, token) for req in requests]
436
+ results = await asyncio.gather(*tasks, return_exceptions=True)
437
+ return [res if not isinstance(res, Exception) else {"success": False, "error": str(res)} for res in results]
438
+
439
+ @app.post("/api/batch/execute_sql_query", response_model=BatchResponse[SqlQueryResponse])
440
+ async def batch_execute_sql(payload: BatchRequest[SqlQueryRequest], token: str = Depends(validate_token)):
441
+ responses = await batch_parallel_handler(execute_mysql_endpoint, payload.requests, token)
442
+ return BatchResponse(responses=responses)
443
+
444
+ @app.post("/api/batch/execute_postgres_query", response_model=BatchResponse[PgQueryResponse])
445
+ async def batch_execute_pg(payload: BatchRequest[PgQueryRequest], token: str = Depends(validate_token)):
446
+ responses = await batch_parallel_handler(execute_postgres_endpoint, payload.requests, token)
447
+ return BatchResponse(responses=responses)
448
+
449
+ @app.post("/api/batch/execute_mongo", response_model=BatchResponse[ExecutorResponse])
450
+ async def batch_execute_mongo(payload: BatchRequest[ExecutorPayload], token: str = Depends(validate_token)):
451
+ responses = await batch_parallel_handler(execute_mongo_endpoint, payload.requests, token)
452
+ return BatchResponse(responses=responses)
453
+
454
+ # ==============================================================================
455
+ # HIGH PERFORMANCE SERVER EXECUTION
456
+ # ==============================================================================
457
+
458
+ if __name__ == "__main__":
459
+ import uvicorn
460
+ host = os.getenv("HOST", "0.0.0.0")
461
+ port = int(os.getenv("PORT", 8000))
462
+
463
+ # Scale processes: 16 Cores - 2 = 14 Workers
464
+ num_workers = max(1, multiprocessing.cpu_count() - 2)
465
+
466
+ print(f"Starting production server on {host}:{port} with {num_workers} workers...")
467
+
468
+ uvicorn.run(
469
+ "controller:app",
470
+ host=host,
471
+ port=port,
472
+ workers=num_workers,
473
+ loop="auto",
474
+ )
csv_analysis_service.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+ import numpy as np
3
+ import io
4
+ import contextlib
5
+ import traceback
6
+ import json
7
+ import logging
8
+ import math
9
+ import re
10
+ from datetime import datetime, date, timedelta
11
+ from typing import Any, Dict, Optional, Union
12
+
13
+ # --- Robust Library Loading ---
14
+ # We try to import common analysis libraries.
15
+ # If they are missing, the service still runs, just without those specifics.
16
+ try:
17
+ import scipy
18
+ import scipy.stats as stats
19
+ except ImportError:
20
+ scipy = None
21
+ stats = None
22
+
23
+ try:
24
+ import sklearn
25
+ from sklearn.linear_model import LinearRegression, LogisticRegression
26
+ from sklearn.model_selection import train_test_split
27
+ from sklearn import metrics
28
+ except ImportError:
29
+ sklearn = None
30
+
31
+ try:
32
+ import statsmodels.api as sm
33
+ import statsmodels.formula.api as smf
34
+ except ImportError:
35
+ sm = None
36
+ smf = None
37
+
38
+ # Configure Logging
39
+ logging.basicConfig(level=logging.INFO)
40
+ logger = logging.getLogger("CSV_Analysis_Executor")
41
+
42
+ def robust_json_serializer(obj: Any) -> Any:
43
+ """
44
+ Universal Serializer.
45
+ 1. Handles DataFrames/Series (returns FULL data).
46
+ 2. Handles NumPy types (int/float/arrays).
47
+ 3. Handles Dates.
48
+ 4. Handles Unknown Objects (Models, Classes) -> returns String Repr.
49
+ """
50
+ # 1. Pandas Types
51
+ if isinstance(obj, pd.DataFrame):
52
+ # UNCONSTRAINED: Return the full dataset as list of dicts
53
+ return obj.to_dict(orient='records')
54
+ elif isinstance(obj, pd.Series):
55
+ return obj.to_dict()
56
+ elif isinstance(obj, pd.Index):
57
+ return obj.tolist()
58
+
59
+ # 2. NumPy Types
60
+ elif isinstance(obj, np.integer):
61
+ return int(obj)
62
+ elif isinstance(obj, np.floating):
63
+ if np.isnan(obj) or np.isinf(obj):
64
+ return None
65
+ return float(obj)
66
+ elif isinstance(obj, np.ndarray):
67
+ return obj.tolist()
68
+ elif isinstance(obj, np.bool_):
69
+ return bool(obj)
70
+
71
+ # 3. Standard Python Dates
72
+ elif isinstance(obj, (datetime, date)):
73
+ return obj.isoformat()
74
+ elif isinstance(obj, timedelta):
75
+ return str(obj)
76
+
77
+ # 4. Fallback for Complex Objects (e.g., sklearn model, statsmodels result)
78
+ # Instead of crashing, we return the string representation
79
+ if hasattr(obj, '__dict__'):
80
+ return str(obj)
81
+
82
+ return str(obj)
83
+
84
+ class CsvAnalysisExecutor:
85
+ """
86
+ A 'Natural' Executor.
87
+ It mimics a local Jupyter notebook environment with common libraries pre-loaded.
88
+ """
89
+ def __init__(self, df: pd.DataFrame):
90
+ # NATURAL MODE: We do NOT sanitize column names.
91
+ # We rely on the code generator to handle keys like df['User ID'] correctly.
92
+ self.df = df
93
+ self.exec_locals = {}
94
+
95
+ def execute(self, code: str) -> Dict[str, Any]:
96
+ """
97
+ Executes code with a rich data science context.
98
+ """
99
+ output_buffer = io.StringIO()
100
+ error_result = None
101
+ success = False
102
+
103
+ # --- Rich Environment Injection ---
104
+ exec_globals = {
105
+ # Core
106
+ 'pd': pd,
107
+ 'np': np,
108
+ 'df': self.df,
109
+ 'math': math,
110
+ 're': re,
111
+ 'json': json,
112
+ 'datetime': datetime,
113
+ 'timedelta': timedelta,
114
+
115
+ # Statistics & ML (if available)
116
+ 'scipy': scipy,
117
+ 'stats': stats,
118
+ 'sklearn': sklearn,
119
+ 'sm': sm,
120
+ 'smf': smf,
121
+
122
+ # Common shortcuts (makes generated code more natural)
123
+ 'LinearRegression': LinearRegression if sklearn else None,
124
+ 'train_test_split': train_test_split if sklearn else None,
125
+ }
126
+
127
+ try:
128
+ # Capture standard output (print statements)
129
+ with contextlib.redirect_stdout(output_buffer):
130
+ exec(code, exec_globals, self.exec_locals)
131
+
132
+ success = True
133
+
134
+ except Exception:
135
+ # Clean traceback for the user
136
+ error_result = traceback.format_exc()
137
+ logger.error(f"Analysis Execution Error:\n{error_result}")
138
+
139
+ # --- Extract "Natural" Results ---
140
+ # We capture everything the user defined, excluding imports and modules
141
+ final_vars = {}
142
+
143
+ # List of system modules to ignore in output
144
+ ignored_types = (type(pd), type(np), type(math), type(json))
145
+
146
+ for k, v in self.exec_locals.items():
147
+ # Skip hidden vars
148
+ if k.startswith('_'): continue
149
+
150
+ # Skip the input dataframe ref (unless they made a copy)
151
+ if k == 'df': continue
152
+
153
+ # Skip modules (e.g., if user did 'import random', don't return the random module)
154
+ if isinstance(v, ignored_types) or hasattr(v, '__name__') and 'module' in str(type(v)):
155
+ continue
156
+
157
+ # Capture the variable
158
+ final_vars[k] = v
159
+
160
+ return {
161
+ "success": success,
162
+ "output_log": output_buffer.getvalue(),
163
+ "error": error_result,
164
+ "data_vars": final_vars
165
+ }
166
+
167
+ def execute_analysis_logic(code: str, csv_url: str) -> Dict[str, Any]:
168
+ """
169
+ Entry point. Loads CSV (handling errors) and executes logic.
170
+ """
171
+ try:
172
+ # 1. Load Data
173
+ logger.info(f"Loading CSV from: {csv_url}")
174
+ try:
175
+ # Use 'on_bad_lines' to be robust against messy CSVs
176
+ df = pd.read_csv(csv_url, on_bad_lines='skip')
177
+ except Exception as e:
178
+ return {
179
+ "success": False,
180
+ "error": f"Failed to load CSV: {str(e)}",
181
+ "output_log": "",
182
+ "results": {}
183
+ }
184
+
185
+ # 2. Execute
186
+ executor = CsvAnalysisExecutor(df)
187
+ result = executor.execute(code)
188
+
189
+ # 3. Serialize (Unconstrained)
190
+ clean_vars = {}
191
+ if result["data_vars"]:
192
+ try:
193
+ # Force strictly valid JSON
194
+ serialized_str = json.dumps(result["data_vars"], default=robust_json_serializer)
195
+ clean_vars = json.loads(serialized_str)
196
+ except Exception as e:
197
+ # Fallback mechanism
198
+ logger.error(f"Serialization Warning: {e}")
199
+ clean_vars = {"serialization_error": str(e), "raw_str_dump": str(result["data_vars"])}
200
+
201
+ return {
202
+ "success": result["success"],
203
+ "error": result["error"],
204
+ "output_log": result["output_log"],
205
+ "results": clean_vars
206
+ }
207
+
208
+ except Exception as e:
209
+ err_msg = f"System Error: {str(e)}\n{traceback.format_exc()}"
210
+ return {
211
+ "success": False,
212
+ "error": err_msg,
213
+ "output_log": "",
214
+ "results": {}
215
+ }
csv_chart_service.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # --- Data Science Stack ---
2
+ from typing import Optional, Tuple
3
+ import uuid
4
+ import pandas as pd
5
+ import numpy as np
6
+ import matplotlib
7
+ import matplotlib.pyplot as plt
8
+ import seaborn as sns
9
+ import datetime as dt
10
+ import io
11
+ import contextlib
12
+ import os
13
+ import traceback
14
+ from dotenv import load_dotenv
15
+
16
+ load_dotenv()
17
+
18
+ # Set Matplotlib to non-interactive mode (server backend)
19
+ matplotlib.use('Agg')
20
+
21
+ def execute_python_code(code: str, csv_url: Optional[str] = None) -> Tuple[Optional[bytes], Optional[str], str]:
22
+ """
23
+ Executes Python code and captures the Matplotlib plot into memory bytes.
24
+ """
25
+ # 1. Define available libraries
26
+ local_scope = {
27
+ "pd": pd, "np": np, "plt": plt, "sns": sns, "dt": dt,
28
+ "uuid": uuid, "os": os, "csv_url": csv_url
29
+ }
30
+
31
+ # 2. [FIX] Load the CSV into 'df' so the AI code can find it
32
+ if csv_url:
33
+ try:
34
+ # Read the CSV from the URL
35
+ df = pd.read_csv(csv_url)
36
+ # Inject it into the local_scope with the variable name 'df'
37
+ local_scope["df"] = df
38
+ except Exception as e:
39
+ # Return early if we can't even load the data
40
+ return None, f"System Error: Failed to load CSV data. {str(e)}", ""
41
+
42
+ stdout_capture = io.StringIO()
43
+
44
+ try:
45
+ plt.clf()
46
+ plt.close('all')
47
+
48
+ with contextlib.redirect_stdout(stdout_capture):
49
+ # 3. Execute the code (now 'df' is defined in local_scope)
50
+ exec(code, {}, local_scope)
51
+
52
+ # Capture the current figure into a BytesIO buffer
53
+ buf = io.BytesIO()
54
+ fig = plt.gcf()
55
+
56
+ # Check if the figure actually has content (axes)
57
+ if not fig.get_axes():
58
+ return None, "No plot was generated by the code.", stdout_capture.getvalue()
59
+
60
+ fig.savefig(buf, format='png', bbox_inches='tight')
61
+ buf.seek(0)
62
+ image_bytes = buf.getvalue()
63
+ buf.close()
64
+
65
+ return image_bytes, None, stdout_capture.getvalue()
66
+
67
+ except Exception:
68
+ return None, traceback.format_exc(), stdout_capture.getvalue()
csv_metadata_service.py ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, Dict, Optional
2
+ import pandas as pd
3
+ import numpy as np
4
+ from pandas.api.types import is_numeric_dtype, is_string_dtype
5
+ from pydantic import BaseModel
6
+
7
+ class CsvInfoRequest(BaseModel):
8
+ csv_url: str
9
+
10
+ class CsvInfoResponse(BaseModel):
11
+ success: bool
12
+ data: Optional[Dict[str, Any]] = None
13
+ error: Optional[str] = None
14
+ request_id: str
15
+ duration: float
16
+
17
+ class CsvDataRequest(BaseModel):
18
+ csv_url: str
19
+
20
+ class PythonExecutionRequest(BaseModel):
21
+ code: str
22
+ context: Optional[Dict[str, Any]] = None
23
+
24
+ class PythonExecutionResponse(BaseModel):
25
+ success: bool
26
+ output: str
27
+ result: Optional[Any] = None
28
+ isStructured: bool
29
+ error: Optional[str] = None
30
+ request_id: str
31
+
32
+ def clean_data(input_data, drop_constants=True):
33
+ """
34
+ The 'Ultimate' generic data cleaner.
35
+ MODIFIED: Keeps column names raw and original (no stripping, no lowercasing).
36
+ """
37
+ try:
38
+ # 1. Flexible Input & Delimiter Detection
39
+ if isinstance(input_data, str):
40
+ try:
41
+ # 'sep=None' with engine='python' attempts to auto-detect delimiters
42
+ df = pd.read_csv(input_data, sep=None, engine='python')
43
+ except UnicodeDecodeError:
44
+ # Fallback to latin1 if utf-8 fails
45
+ df = pd.read_csv(input_data, sep=None, engine='python', encoding='latin1')
46
+ except Exception:
47
+ # Final fallback to standard read_csv
48
+ df = pd.read_csv(input_data)
49
+ elif isinstance(input_data, pd.DataFrame):
50
+ df = input_data.copy()
51
+ else:
52
+ raise ValueError("Input must be a CSV URL string or a pandas DataFrame.")
53
+
54
+ # 2. Standardize Column Names -> SKIPPED
55
+ # We keep the raw column names exactly as they are in the source file.
56
+ # df.columns = df.columns... (Removed)
57
+
58
+ # 3. Remove Duplicate Rows
59
+ df = df.drop_duplicates()
60
+
61
+ # 4. Intelligent Type Inference
62
+ for col in df.columns:
63
+ # Skip if already numeric
64
+ if is_numeric_dtype(df[col]):
65
+ continue
66
+
67
+ # A. Number Parsing (remove currency symbols etc)
68
+ if is_string_dtype(df[col]):
69
+ clean_col = df[col].astype(str).str.replace(r'[$,%]', '', regex=True)
70
+ converted = pd.to_numeric(clean_col, errors='coerce')
71
+ # Only apply if it converts the majority of the data
72
+ if converted.notna().mean() > 0.8:
73
+ df[col] = converted
74
+ continue
75
+
76
+ # B. Date Parsing
77
+ if is_string_dtype(df[col]):
78
+ try:
79
+ sample = str(df[col].dropna().iloc[0]) if not df[col].dropna().empty else ""
80
+ # Simple heuristic to check if it looks like a date
81
+ is_date_like = any(x in sample for x in ['-', '/', ':'])
82
+ if is_date_like:
83
+ converted = pd.to_datetime(df[col], errors='coerce')
84
+ if converted.notna().mean() > 0.8:
85
+ df[col] = converted
86
+ except Exception:
87
+ pass
88
+
89
+ # 5. Handle Infinite Values
90
+ df.replace([np.inf, -np.inf], np.nan, inplace=True)
91
+
92
+ # 6. Robust Missing Value Filling
93
+ # Fill numeric columns with 0
94
+ num_cols = df.select_dtypes(include=[np.number]).columns
95
+ df[num_cols] = df[num_cols].fillna(0)
96
+
97
+ # Fill categorical/object columns with 'Unknown'
98
+ cat_cols = df.select_dtypes(include=['object', 'category']).columns
99
+ for col in cat_cols:
100
+ if df[col].dtype.name == 'category':
101
+ if 'Unknown' not in df[col].cat.categories:
102
+ df[col] = df[col].cat.add_categories(['Unknown'])
103
+ df[col] = df[col].fillna('Unknown')
104
+ else:
105
+ df[col] = df[col].fillna('Unknown')
106
+
107
+ # Fill boolean columns with False
108
+ bool_cols = df.select_dtypes(include=['bool']).columns
109
+ df[bool_cols] = df[bool_cols].fillna(False)
110
+
111
+ # 7. Remove Constant Columns (columns with only 1 unique value)
112
+ if drop_constants:
113
+ cols_to_drop = [col for col in df.columns if df[col].nunique() <= 1]
114
+ if cols_to_drop:
115
+ df = df.drop(columns=cols_to_drop)
116
+
117
+ return df
118
+
119
+ except Exception as e:
120
+ raise Exception(f"Data Cleaning Failed: {str(e)}")
121
+
122
+ def get_csv_basic_info(csv_path):
123
+ """
124
+ Get basic information about a CSV file.
125
+ Includes JSON serialization fix for dates and numpy types.
126
+ """
127
+ try:
128
+ # Read and clean the CSV file
129
+ df = clean_data(csv_path)
130
+
131
+ # Helper to make data JSON compliant (Fixes Timestamp and NaN issues)
132
+ def json_serializable(val):
133
+ if pd.isna(val):
134
+ return None
135
+ if isinstance(val, (pd.Timestamp, np.datetime64)):
136
+ return str(val) # Convert date to string
137
+ if isinstance(val, (np.integer, np.int64)):
138
+ return int(val)
139
+ if isinstance(val, (np.floating, np.float64)):
140
+ return float(val)
141
+ return val
142
+
143
+ # Extract first row and sanitize it
144
+ raw_sample = df.head(1).to_dict('records')
145
+ clean_sample = []
146
+
147
+ if raw_sample:
148
+ clean_sample = [{k: json_serializable(v) for k, v in raw_sample[0].items()}]
149
+
150
+ print(f"CSV file read successfully: {csv_path}")
151
+
152
+ info = {
153
+ 'num_rows': int(len(df)), # Ensure Python int, not numpy int
154
+ 'num_cols': int(len(df.columns)),
155
+ 'example_rows': clean_sample, # Use the sanitized sample
156
+ 'dtypes': {col: str(df[col].dtype) for col in df.columns},
157
+ 'columns': list(df.columns),
158
+ 'numeric_columns': [col for col in df.columns if pd.api.types.is_numeric_dtype(df[col])],
159
+ 'categorical_columns': [col for col in df.columns if pd.api.types.is_string_dtype(df[col])]
160
+ }
161
+ return info
162
+ except Exception as e:
163
+ error_info = {
164
+ 'error': f"Error reading CSV file: {str(e)}",
165
+ }
166
+ return error_info
167
+
168
+
169
+ def get_robust_csv_rows(csv_url: str):
170
+ """
171
+ Reads a CSV securely and robustly for frontend table rendering.
172
+ - Auto-detects delimiters (semicolon vs comma).
173
+ - Handles encoding issues.
174
+ - Replaces NaNs with empty strings for JSON safety.
175
+ """
176
+ try:
177
+ # 1. Robust Reading (Auto-detect separator, handle encoding)
178
+ try:
179
+ df = pd.read_csv(csv_url, sep=None, engine='python')
180
+ except UnicodeDecodeError:
181
+ df = pd.read_csv(csv_url, sep=None, engine='python', encoding='latin1')
182
+ except Exception:
183
+ # Fallback to standard C engine if python engine fails
184
+ df = pd.read_csv(csv_url)
185
+
186
+ # 2. Clean for JSON Rendering
187
+ # Replace infinite values with NaN
188
+ df.replace([np.inf, -np.inf], np.nan, inplace=True)
189
+
190
+ # Replace NaN with empty string (better for UI tables than 'null')
191
+ df = df.fillna("")
192
+
193
+ # 3. Convert to List of Dictionaries
194
+ data_list = df.to_dict(orient='records')
195
+
196
+ return data_list
197
+
198
+ except Exception as e:
199
+ return {"error": f"Failed to read CSV: {str(e)}"}
200
+
201
+ #--------- GENERIC MODAL CODE EXECUTION LOGIC ---------
202
+ import io
203
+ from contextlib import redirect_stdout, redirect_stderr
204
+ from typing import Any, Dict
205
+ import requests
206
+
207
+
208
+ def check_structured_data(data: Any) -> bool:
209
+ if isinstance(data, list) and data and all(isinstance(item, dict) for item in data):
210
+ return all(
211
+ all(isinstance(v, (str, int, float, bool)) or v is None for v in item.values())
212
+ for item in data
213
+ )
214
+ elif isinstance(data, dict):
215
+ return all(isinstance(v, (str, int, float, bool)) or v is None for v in data.values())
216
+ return False
217
+
218
+
219
+ def clean_output(stdout: str, stderr: str) -> str:
220
+ output = []
221
+ if stdout.strip():
222
+ output.append(stdout.strip())
223
+ if stderr.strip():
224
+ output.append(stderr.strip())
225
+ return '\n'.join(output) if output else ''
226
+
227
+
228
+ def execute_python_logic(code: str, custom_context: dict = None) -> Dict[str, Any]:
229
+ stdout = io.StringIO()
230
+ stderr = io.StringIO()
231
+ result = None
232
+ is_structured = False
233
+ error = None
234
+
235
+ try:
236
+ with redirect_stdout(stdout), redirect_stderr(stderr):
237
+ exec_globals = {
238
+ '__builtins__': __builtins__,
239
+ 'requests': requests,
240
+ 'print': print,
241
+ }
242
+
243
+ try:
244
+ compiled = compile(code, '<string>', 'eval')
245
+ result = eval(compiled, exec_globals)
246
+ except SyntaxError:
247
+ compiled = compile(code, '<string>', 'exec')
248
+ exec(compiled, exec_globals)
249
+ result = exec_globals.get('result') or exec_globals.get('_')
250
+
251
+ if result is not None:
252
+ is_structured = check_structured_data(result)
253
+
254
+ except Exception as e:
255
+ error = f"Execution error: {str(e)}"
256
+ stderr.write(error)
257
+
258
+ output = clean_output(stdout.getvalue(), stderr.getvalue())
259
+
260
+ return {
261
+ 'output': output,
262
+ 'result': result,
263
+ 'isStructured': is_structured,
264
+ 'error': error
265
+ }
mongo_service.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import logging
3
+ from typing import Dict, List, Union
4
+ from bson import ObjectId
5
+ import ast
6
+ import json
7
+ import re
8
+ from pymongo.collation import Collation
9
+ from pymongo import MongoClient
10
+
11
+ logging.basicConfig(
12
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
13
+ level=logging.INFO
14
+ )
15
+ logger = logging.getLogger("mongo_service")
16
+
17
+ def convert_oid(obj):
18
+ """Recursively convert $oid format or ObjectId objects to strings."""
19
+ if isinstance(obj, ObjectId):
20
+ return str(obj)
21
+ if isinstance(obj, dict):
22
+ if set(obj.keys()) == {"$oid"}:
23
+ return str(obj["$oid"])
24
+ return {k: convert_oid(v) for k, v in obj.items()}
25
+ elif isinstance(obj, list):
26
+ return [convert_oid(item) for item in obj]
27
+ else:
28
+ return obj
29
+
30
+ def parse_query_input(query_input: Union[str, Dict, List]) -> Union[Dict, List]:
31
+ """Ensures the input is a valid MongoDB query object (Dict or List)."""
32
+ if isinstance(query_input, (dict, list)):
33
+ return convert_oid(query_input)
34
+
35
+ query_str = str(query_input).strip()
36
+
37
+ # 1. Try JSON
38
+ try:
39
+ return json.loads(query_str)
40
+ except json.JSONDecodeError:
41
+ pass
42
+
43
+ # 2. Extract code block
44
+ match = re.search(r"```(?:python|json|javascript)?\s*(.*?)\s*```", query_str, re.DOTALL)
45
+ if match:
46
+ query_str = match.group(1).strip()
47
+
48
+ # 3. Clean up MongoDB shell syntax
49
+ query_str = re.sub(r'^db\.\w+\.\w+\(', '', query_str)
50
+ if query_str.endswith(')'):
51
+ query_str = query_str[:-1]
52
+
53
+ # 4. AST Literal Eval
54
+ try:
55
+ parsed = ast.literal_eval(query_str)
56
+ return convert_oid(parsed)
57
+ except (ValueError, SyntaxError) as e:
58
+ logger.error(f"Failed to parse query string: {e}")
59
+ raise ValueError(f"Could not parse query string: {str(e)}")
60
+
61
+ def get_value_ignore_case(d: Dict, keys: List[str], default=None):
62
+ """Helper to get value checking multiple key variations (camelCase/snake_case)."""
63
+ for k in keys:
64
+ if k in d:
65
+ return d[k]
66
+ return default
67
+
68
+ def execute_mongo_operation(
69
+ mongo_uri: str,
70
+ db_name: str,
71
+ collection_name: str,
72
+ query: Union[Dict, List],
73
+ limited: bool = False, # <--- Added
74
+ limit_rows: int = 20 # <--- Added
75
+ ):
76
+ """
77
+ Generic MongoDB Executor.
78
+ Supports: Aggregation Pipelines (List) and Find Operations (Dict).
79
+ Now supports explicit row limiting.
80
+ """
81
+ client = None
82
+ try:
83
+ client = MongoClient(mongo_uri, serverSelectionTimeoutMS=5000, connectTimeoutMS=5000)
84
+ db = client[db_name]
85
+ collection = db[collection_name]
86
+
87
+ results = []
88
+
89
+ # --- Aggregation ---
90
+ if isinstance(query, list):
91
+ logger.info("Executing Aggregation Pipeline")
92
+
93
+ # Apply Limit if requested and not already present at the end
94
+ if limited:
95
+ # Check if the last stage is already a $limit
96
+ if not (query and "$limit" in query[-1]):
97
+ logger.info(f"Injecting $limit: {limit_rows} to pipeline")
98
+ query.append({"$limit": limit_rows})
99
+
100
+ cursor = collection.aggregate(query, allowDiskUse=True)
101
+ results = list(cursor)
102
+
103
+ # --- Find / Command ---
104
+ elif isinstance(query, dict):
105
+ logger.info("Executing Structured Find Query")
106
+
107
+ command_keys = {'filter', 'query', '$query', 'projection', 'sort', 'limit', 'skip'}
108
+ has_command_keys = bool(set(query.keys()) & command_keys)
109
+
110
+ cursor = None
111
+
112
+ if has_command_keys and ('filter' in query or 'query' in query or '$query' in query):
113
+ # It is a Structured Command
114
+ query_filter = get_value_ignore_case(query, ['filter', 'query', '$query'], {})
115
+ projection = get_value_ignore_case(query, ['projection', 'fields'], None)
116
+ if projection == {}: projection = None
117
+
118
+ # Check internal limit
119
+ internal_limit = int(get_value_ignore_case(query, ['limit'], 0))
120
+ skip = int(get_value_ignore_case(query, ['skip'], 0))
121
+ sort_val = get_value_ignore_case(query, ['sort', '$orderby'], None)
122
+
123
+ cursor = collection.find(query_filter, projection)
124
+
125
+ if sort_val:
126
+ if isinstance(sort_val, dict):
127
+ sort_val = list(sort_val.items())
128
+ cursor = cursor.sort(sort_val)
129
+
130
+ if skip > 0: cursor = cursor.skip(skip)
131
+
132
+ # Logic: If 'limited' is forced (Chat Mode), use limit_rows.
133
+ # Otherwise use the query's internal limit if it exists.
134
+ if limited:
135
+ cursor = cursor.limit(limit_rows)
136
+ elif internal_limit > 0:
137
+ cursor = cursor.limit(internal_limit)
138
+
139
+ else:
140
+ # It is a Raw Filter
141
+ logger.info("Treating input dictionary as Raw Filter")
142
+ cursor = collection.find(query)
143
+
144
+ if limited:
145
+ cursor = cursor.limit(limit_rows)
146
+
147
+ results = list(cursor)
148
+ else:
149
+ raise ValueError("Query must be a Dictionary (find) or List (aggregate)")
150
+
151
+ return results
152
+
153
+ except Exception as e:
154
+ logger.error(f"DB Execution Error: {e}")
155
+ raise e
156
+ finally:
157
+ if client:
158
+ client.close()
pydantic_csv_analysis_model.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, Dict, Optional
2
+ from pydantic import BaseModel
3
+
4
+
5
+ class AnalysisRequest(BaseModel):
6
+ csv_url: str
7
+ code: str
8
+
9
+ class AnalysisResponse(BaseModel):
10
+ success: bool
11
+ output_log: str
12
+ results: Dict[str, Any] # Unconstrained Dictionary
13
+ error: Optional[str] = None
14
+ request_id: str
pydantic_csv_charts_model.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # --- Chart Models ---
3
+ from typing import Optional
4
+ from pydantic import BaseModel, Field
5
+
6
+ # --- Chart Models ---
7
+ class ChartExecutionPayload(BaseModel):
8
+ code: str = Field(..., description="The Python code to execute.")
9
+ csv_url: Optional[str] = Field(None, description="Optional CSV URL to be injected into scope")
10
+ chat_id: str = Field(..., description="Chat ID required for Supabase mapping")
11
+ return_base64: bool = Field(False, description="If True, returns base64 string instead of uploading to Supabase")
12
+
13
+ class ChartExecutionResponse(BaseModel):
14
+ status: str
15
+ image_url: Optional[str] = None
16
+ base64_image: Optional[str] = None
17
+ output_log: Optional[str] = None
18
+ error: Optional[str] = None
19
+ request_id: str
pydantic_mongo_executor_model.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # --- Mongo Models ---
3
+ from typing import Any, Dict, List, Optional, Union
4
+ from pydantic import BaseModel, Field
5
+
6
+
7
+ class ExecutorPayload(BaseModel):
8
+ mongo_uri: str = Field(..., description="MongoDB connection string")
9
+ db_name: str = Field(..., description="Target Database Name")
10
+ collection_name: str = Field(..., description="Target Collection Name")
11
+ generated_query: Union[Dict, List, str] = Field(..., description="The query to execute (Find dict or Aggregation list)")
12
+ user_query: Optional[str] = Field(None, description="Original user question for logging context")
13
+ limited: bool = False
14
+ limit_rows: int = 20
15
+
16
+ class ExecutorResponse(BaseModel):
17
+ status: str
18
+ count: int
19
+ data: Any
20
+ duration_seconds: float
21
+ request_id: str
report_service.py ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+ import matplotlib.pyplot as plt
3
+ import seaborn as sns
4
+ import datetime as dt
5
+ import os
6
+ import uuid
7
+ import json
8
+ import logging
9
+ import sys
10
+ import traceback
11
+ import shutil
12
+ import ast # <--- Added for syntax checking
13
+ from io import StringIO
14
+ from typing import List, Dict, Any, Optional
15
+ from pydantic import BaseModel
16
+ from starlette.concurrency import run_in_threadpool
17
+ import numpy as np
18
+
19
+ # --- Internal Imports ---
20
+ from supabase_service import upload_file_to_supabase
21
+
22
+ # Configure Logging
23
+ logging.basicConfig(level=logging.INFO)
24
+ logger = logging.getLogger("Report_Generator")
25
+
26
+ # Configure Matplotlib backend for thread safety (Headless)
27
+ os.environ['MPLBACKEND'] = 'agg'
28
+
29
+ # Disable plt.show globally to prevent blocking
30
+ plt.show = lambda: None
31
+
32
+ # ==============================================================================
33
+ # REQUEST MODELS
34
+ # ==============================================================================
35
+ class ReportRequest(BaseModel):
36
+ code: str
37
+ csv_url: str
38
+ chat_id: str
39
+
40
+ # ==============================================================================
41
+ # RESPONSE MODELS
42
+ # ==============================================================================
43
+
44
+ class FileProps(BaseModel):
45
+ fileName: str
46
+ filePath: str
47
+ fileType: str # 'csv' | 'image'
48
+
49
+ class Files(BaseModel):
50
+ csv_files: List[FileProps]
51
+ image_files: List[FileProps]
52
+
53
+ class FileBoxProps(BaseModel):
54
+ files: Files
55
+
56
+ # ==============================================================================
57
+ # EXECUTION ENGINE
58
+ # ==============================================================================
59
+
60
+ class PythonREPL:
61
+ """Secure Python REPL with file generation tracking"""
62
+
63
+ def __init__(self, df: pd.DataFrame):
64
+ self.df = df
65
+ # Create a unique directory for this execution to avoid thread collisions
66
+ self.output_dir = os.path.abspath(f'generated_outputs/{uuid.uuid4()}')
67
+ os.makedirs(self.output_dir, exist_ok=True)
68
+
69
+ self.local_env = {
70
+ "pd": pd,
71
+ "df": self.df.copy(),
72
+ "plt": plt,
73
+ "os": os,
74
+ "uuid": uuid,
75
+ "sns": sns,
76
+ "json": json,
77
+ "dt": dt,
78
+ "np": np,
79
+ "output_dir": self.output_dir # INJECT output_dir so code can use it
80
+ }
81
+
82
+ def execute(self, code: str) -> Dict[str, Any]:
83
+ logger.info(f'Executing code in: {self.output_dir}')
84
+ old_stdout = sys.stdout
85
+ sys.stdout = mystdout = StringIO()
86
+
87
+ file_tracker = {
88
+ 'csv_files': set(),
89
+ 'image_files': set()
90
+ }
91
+
92
+ # Wrap code to ensure non-interactive plotting inside the exec scope
93
+ wrapped_code = f"""
94
+ import matplotlib.pyplot as plt
95
+ plt.switch_backend('agg')
96
+ {code}
97
+ plt.close('all')
98
+ """
99
+ error = False
100
+ error_msg = None
101
+
102
+ try:
103
+ # --- IMPROVEMENT: Syntax Validation ---
104
+ # This checks if the generated code is valid Python before running it.
105
+ # It catches the "missing parenthesis" error gracefully.
106
+ try:
107
+ ast.parse(wrapped_code)
108
+ except SyntaxError as e:
109
+ raise SyntaxError(f"Generated code is incomplete or invalid: {e}")
110
+
111
+ # Execute the code
112
+ exec(wrapped_code, self.local_env)
113
+
114
+ # Check for generated files in the specific directory
115
+ if os.path.exists(self.output_dir):
116
+ for fname in os.listdir(self.output_dir):
117
+ if fname.endswith('.csv'):
118
+ file_tracker['csv_files'].add(fname)
119
+ elif fname.lower().endswith(('.png', '.jpg', '.jpeg')):
120
+ file_tracker['image_files'].add(fname)
121
+
122
+ except Exception as e:
123
+ error = True
124
+ error_msg = traceback.format_exc()
125
+
126
+ # --- IMPROVEMENT: Better Logging ---
127
+ # Log the code causing the error so you can debug the LLM prompt
128
+ logger.error("============= CODE EXECUTION FAILED =============")
129
+ logger.error(f"Error Message: {str(e)}")
130
+ logger.error("---------------- FAILED CODE ----------------")
131
+ for i, line in enumerate(wrapped_code.split('\n')):
132
+ logger.error(f"{i+1}: {line}")
133
+ logger.error("=================================================")
134
+
135
+ finally:
136
+ sys.stdout = old_stdout
137
+
138
+ return {
139
+ "output": mystdout.getvalue(),
140
+ "error": error,
141
+ "error_message": error_msg,
142
+ "output_dir": self.output_dir,
143
+ "files": {
144
+ "csv": [os.path.join(self.output_dir, f) for f in file_tracker['csv_files']],
145
+ "images": [os.path.join(self.output_dir, f) for f in file_tracker['image_files']]
146
+ }
147
+ }
148
+
149
+ def cleanup(self):
150
+ """Remove the temporary directory"""
151
+ if os.path.exists(self.output_dir):
152
+ try:
153
+ shutil.rmtree(self.output_dir)
154
+ except Exception as e:
155
+ logger.warning(f"Cleanup failed: {e}")
156
+
157
+ # ==============================================================================
158
+ # MAIN LOGIC (Synchronous Worker)
159
+ # ==============================================================================
160
+
161
+ def _generate_report_sync(code: str, csv_url: str, chat_id: str) -> FileBoxProps:
162
+ """
163
+ Blocking worker function.
164
+ 1. Reads CSV.
165
+ 2. Runs Code.
166
+ 3. Uploads files synchronously.
167
+ """
168
+ repl = None
169
+ csv_props = []
170
+ image_props = []
171
+
172
+ try:
173
+ # 1. Load Data
174
+ df = pd.read_csv(csv_url)
175
+
176
+ # 2. Initialize REPL
177
+ repl = PythonREPL(df)
178
+
179
+ # 3. Execute Code
180
+ result = repl.execute(code)
181
+
182
+ if result['error']:
183
+ # Log is already handled in repl.execute
184
+ return FileBoxProps(files=Files(csv_files=[], image_files=[]))
185
+
186
+ # 4. Process & Upload CSVs
187
+ for csv_path in result['files']['csv']:
188
+ if os.path.exists(csv_path):
189
+ file_name = os.path.basename(csv_path)
190
+ unique_name = f"{uuid.uuid4()}_{file_name}"
191
+ try:
192
+ # Sync call - NO AWAIT
193
+ public_url = upload_file_to_supabase(
194
+ file_path=csv_path,
195
+ file_name=unique_name,
196
+ chat_id=chat_id
197
+ )
198
+ csv_props.append(FileProps(
199
+ fileName=file_name,
200
+ filePath=public_url,
201
+ fileType="csv"
202
+ ))
203
+ except Exception as e:
204
+ logger.error(f"Failed upload CSV {file_name}: {e}")
205
+
206
+ # 5. Process & Upload Images
207
+ for img_path in result['files']['images']:
208
+ if os.path.exists(img_path):
209
+ file_name = os.path.basename(img_path)
210
+ unique_name = f"{uuid.uuid4()}_{file_name}"
211
+ try:
212
+ # Sync call - NO AWAIT
213
+ public_url = upload_file_to_supabase(
214
+ file_path=img_path,
215
+ file_name=unique_name,
216
+ chat_id=chat_id
217
+ )
218
+ image_props.append(FileProps(
219
+ fileName=file_name,
220
+ filePath=public_url,
221
+ fileType="image"
222
+ ))
223
+ except Exception as e:
224
+ logger.error(f"Failed upload Image {file_name}: {e}")
225
+
226
+ return FileBoxProps(
227
+ files=Files(
228
+ csv_files=csv_props,
229
+ image_files=image_props
230
+ )
231
+ )
232
+
233
+ except Exception as e:
234
+ logger.error(f"System Error in Report Generator: {e}")
235
+ return FileBoxProps(files=Files(csv_files=[], image_files=[]))
236
+
237
+ finally:
238
+ # 6. Cleanup local files
239
+ if repl:
240
+ repl.cleanup()
241
+
242
+ # ==============================================================================
243
+ # ASYNC WRAPPER
244
+ # ==============================================================================
245
+
246
+ async def execute_report_generation(code: str, csv_url: str, chat_id: str) -> FileBoxProps:
247
+ """
248
+ Async entry point that runs the blocking logic in a separate thread.
249
+ This enables concurrency.
250
+ """
251
+ return await run_in_threadpool(
252
+ _generate_report_sync,
253
+ code=code,
254
+ csv_url=csv_url,
255
+ chat_id=chat_id
256
+ )
supabase_service.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import os
3
+ from typing import Optional
4
+ from dotenv import load_dotenv
5
+ from supabase import create_client, Client
6
+
7
+ logging.basicConfig(
8
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
9
+ level=logging.INFO
10
+ )
11
+ logger = logging.getLogger("supabase_service")
12
+
13
+ load_dotenv()
14
+
15
+ # --- Supabase Setup ---
16
+ SUPABASE_URL = os.getenv("SUPABASE_URL")
17
+ SUPABASE_KEY = os.getenv("SUPABASE_KEY")
18
+ BUCKET_NAME = "csvcharts"
19
+
20
+ # Initialize Supabase Client
21
+ supabase: Optional[Client] = None
22
+ if SUPABASE_URL and SUPABASE_KEY:
23
+ try:
24
+ supabase = create_client(SUPABASE_URL, SUPABASE_KEY)
25
+ logger.info("Supabase client initialized successfully.")
26
+ except Exception as e:
27
+ logger.error(f"Failed to initialize Supabase: {e}")
28
+ else:
29
+ logger.warning("WARNING: SUPABASE_URL or SUPABASE_KEY not set. Uploads will fail.")
30
+
31
+
32
+
33
+ def upload_file_to_supabase(file_path: str, file_name: str, chat_id: str) -> str:
34
+ """
35
+ Uploads an image to Supabase Storage and returns the public URL.
36
+ Saves the mapping (url, name, chat_id) in the DB.
37
+ """
38
+ if not supabase:
39
+ raise Exception("Supabase client is not initialized. Check .env variables.")
40
+
41
+ if not os.path.exists(file_path):
42
+ raise FileNotFoundError(f"The file {file_path} does not exist.")
43
+
44
+ with open(file_path, "rb") as f:
45
+ file_data = f.read()
46
+
47
+ # 1. Upload to Storage
48
+ try:
49
+ res = supabase.storage.from_(BUCKET_NAME).upload(file_name, file_data)
50
+ logger.info(f"Supabase upload response: {res}")
51
+ except Exception as e:
52
+ raise Exception(f"Failed to upload file to Supabase Storage: {e}")
53
+
54
+ # 2. Get Public URL
55
+ public_url = supabase.storage.from_(BUCKET_NAME).get_public_url(file_name)
56
+ logger.info(f"Generated Public URL: {public_url}")
57
+
58
+ # 3. Save mapping to Database
59
+ try:
60
+ supabase.table("chart_mappings").insert({
61
+ "public_url": public_url,
62
+ "chart_name": file_name,
63
+ "chat_id": chat_id
64
+ }).execute()
65
+ except Exception as e:
66
+ logger.error(f"Failed to save mapping to DB: {e}")
67
+ # Cleanup uploaded file to avoid orphans
68
+ try:
69
+ supabase.storage.from_(BUCKET_NAME).remove([file_name])
70
+ except:
71
+ pass
72
+ raise Exception(f"Failed to save mapping to database: {e}")
73
+
74
+ return public_url
75
+
76
+
77
+ def upload_bytes_to_supabase(image_bytes: bytes, file_name: str, chat_id: str) -> str:
78
+ """
79
+ Uploads raw bytes directly to Supabase.
80
+ """
81
+ if not supabase:
82
+ raise Exception("Supabase client not initialized.")
83
+
84
+ # 1. Upload bytes directly
85
+ try:
86
+ # Supabase Python SDK accepts bytes directly
87
+ supabase.storage.from_(BUCKET_NAME).upload(
88
+ path=file_name,
89
+ file=image_bytes,
90
+ file_options={"content-type": "image/png"}
91
+ )
92
+ except Exception as e:
93
+ raise Exception(f"Supabase Storage error: {e}")
94
+
95
+ # 2. Get Public URL
96
+ public_url = supabase.storage.from_(BUCKET_NAME).get_public_url(file_name)
97
+
98
+ # 3. Save mapping to Database
99
+ supabase.table("chart_mappings").insert({
100
+ "public_url": public_url,
101
+ "chart_name": file_name,
102
+ "chat_id": chat_id
103
+ }).execute()
104
+
105
+ return public_url