Code-API commited on
Commit
64a008c
·
1 Parent(s): 3235c48

deploy: auto-deploy 17:08:05

Browse files
app/api/v1/csv_analysis.py ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Annotated, Any, Dict, List, Optional
5
+
6
+ from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
7
+ from pydantic import BaseModel, Field, ValidationError
8
+
9
+ from app.api.deps import require_auth
10
+ from app.config import get_settings
11
+ from app.services.chat_service import chat_completion
12
+ from app.services.csv_analysis_service import (
13
+ analyze_csv_dataset,
14
+ create_csv_chart,
15
+ execute_csv_chat_blocks,
16
+ get_dataset_info,
17
+ )
18
+ from app.services.prompts import get_csv_system_prompt
19
+ from app.utils.json_utils import extract_json_blocks
20
+
21
+
22
+ class _AnalyzeBlock(BaseModel):
23
+ description: str = ""
24
+ python_code: str = ""
25
+
26
+
27
+ class _VisualizationBlock(BaseModel):
28
+ description: str = ""
29
+ python_code: str = ""
30
+
31
+
32
+ class _AIResponse(BaseModel):
33
+ analyze: List[_AnalyzeBlock] = []
34
+ visualization: List[_VisualizationBlock] = []
35
+ message: str = ""
36
+
37
+ router = APIRouter()
38
+ _settings = get_settings()
39
+ _MAX_UPLOAD_BYTES = _settings.max_upload_bytes
40
+
41
+
42
+ @router.post(
43
+ "/csv/info",
44
+ summary="Get metadata for up to 10 CSV files (upload or URL)",
45
+ )
46
+ async def get_csv_info(
47
+ files: Annotated[Optional[List[UploadFile]], File(description="CSV files to inspect (max 10 total with URLs)")] = None,
48
+ urls: Annotated[Optional[str], Form(description="JSON array of file URLs (max 10 total with files)")] = None,
49
+ token: str = Depends(require_auth),
50
+ ):
51
+ parsed_urls: List[str] = []
52
+ if urls:
53
+ try:
54
+ parsed_urls = json.loads(urls)
55
+ if not isinstance(parsed_urls, list) or not all(isinstance(u, str) for u in parsed_urls):
56
+ raise ValueError("urls must be a JSON array of strings")
57
+ except (json.JSONDecodeError, ValueError) as exc:
58
+ raise HTTPException(status_code=400, detail=str(exc))
59
+
60
+ file_count = len(files) if files else 0
61
+ url_count = len(parsed_urls)
62
+ total = file_count + url_count
63
+
64
+ if total == 0:
65
+ raise HTTPException(status_code=400, detail="Provide at least one file or URL")
66
+ if total > 10:
67
+ raise HTTPException(status_code=400, detail=f"Maximum 10 sources allowed (got {total})")
68
+
69
+ results: List[dict] = []
70
+
71
+ if files:
72
+ for f in files:
73
+ try:
74
+ data = await f.read()
75
+ except Exception as exc:
76
+ results.append({"source": getattr(f, "filename", "unknown"), "success": False, "error": f"Read error: {exc}"})
77
+ continue
78
+
79
+ if len(data) > _MAX_UPLOAD_BYTES:
80
+ results.append({"source": f.filename or "unknown", "success": False, "error": f"File exceeds {_settings.max_upload_mb} MB limit"})
81
+ continue
82
+
83
+ if not data:
84
+ results.append({"source": f.filename or "unknown", "success": False, "error": "Empty file"})
85
+ continue
86
+
87
+ try:
88
+ meta = await get_dataset_info(data)
89
+ meta["source"] = f.filename or "upload"
90
+ results.append(meta)
91
+ except Exception as exc:
92
+ results.append({"source": f.filename or "upload", "success": False, "error": str(exc)})
93
+
94
+ for url in parsed_urls:
95
+ if not url.startswith(("http://", "https://")):
96
+ results.append({"source": url, "success": False, "error": "Only http/https URLs are supported"})
97
+ continue
98
+
99
+ try:
100
+ meta = await get_dataset_info(url)
101
+ meta["source"] = url
102
+ results.append(meta)
103
+ except Exception as exc:
104
+ results.append({"source": url, "success": False, "error": str(exc)})
105
+
106
+ return {
107
+ "success": True,
108
+ "total": total,
109
+ "succeeded": sum(1 for r in results if r.get("success")),
110
+ "failed": sum(1 for r in results if not r.get("success")),
111
+ "results": results,
112
+ }
113
+
114
+
115
+ # @router.post(
116
+ # "/csv/analyze",
117
+ # summary="Execute Python analysis code against a CSV file (upload or URL)",
118
+ # )
119
+ # async def analyze_csv(
120
+ # file: Annotated[Optional[UploadFile], File(description="CSV file to analyze")] = None,
121
+ # url: Annotated[Optional[str], Form(description="URL to a CSV file")] = None,
122
+ # code: str = Form(..., description="Python code to execute (df pre-loaded with CSV data)"),
123
+ # token: str = Depends(require_auth),
124
+ # ):
125
+ # if not file and not url:
126
+ # raise HTTPException(status_code=400, detail="Provide either a file or a URL")
127
+
128
+ # if file and url:
129
+ # raise HTTPException(status_code=400, detail="Provide either a file or a URL, not both")
130
+
131
+ # if file:
132
+ # data = await file.read()
133
+ # if len(data) > _MAX_UPLOAD_BYTES:
134
+ # raise HTTPException(status_code=413, detail=f"File exceeds {_settings.max_upload_mb} MB limit")
135
+ # if not data:
136
+ # raise HTTPException(status_code=400, detail="Empty file")
137
+ # result = await analyze_csv_dataset(data, code)
138
+ # else:
139
+ # result = await analyze_csv_dataset(url, code)
140
+
141
+ # return result
142
+
143
+
144
+ # @router.post(
145
+ # "/csv/chart",
146
+ # summary="Generate a chart from CSV data and return as base64 PNG (upload or URL)",
147
+ # )
148
+ # async def chart_csv(
149
+ # file: Annotated[Optional[UploadFile], File(description="CSV file for chart generation")] = None,
150
+ # url: Annotated[Optional[str], Form(description="URL to a CSV file")] = None,
151
+ # code: str = Form(..., description="Python chart code (df pre-loaded, use matplotlib/seaborn)"),
152
+ # token: str = Depends(require_auth),
153
+ # ):
154
+ # if not file and not url:
155
+ # raise HTTPException(status_code=400, detail="Provide either a file or a URL")
156
+
157
+ # if file and url:
158
+ # raise HTTPException(status_code=400, detail="Provide either a file or a URL, not both")
159
+
160
+ # if file:
161
+ # data = await file.read()
162
+ # if len(data) > _MAX_UPLOAD_BYTES:
163
+ # raise HTTPException(status_code=413, detail=f"File exceeds {_settings.max_upload_mb} MB limit")
164
+ # if not data:
165
+ # raise HTTPException(status_code=400, detail="Empty file")
166
+ # result = await create_csv_chart(data, code)
167
+ # else:
168
+ # result = await create_csv_chart(url, code)
169
+
170
+ # return result
171
+
172
+
173
+ @router.post(
174
+ "/csv/chat",
175
+ summary="Chat with AI about a CSV file — returns analysis + chart code results",
176
+ )
177
+ async def csv_chat(
178
+ request: Request,
179
+ file: Annotated[Optional[UploadFile], File(description="CSV file to analyze")] = None,
180
+ url: Annotated[Optional[str], Form(description="URL to a CSV file")] = None,
181
+ query: str = Form(..., description="Natural language query about the CSV data"),
182
+ token: str = Depends(require_auth),
183
+ ):
184
+ if not file and not url:
185
+ raise HTTPException(status_code=400, detail="Provide either a file or a URL")
186
+ if file and url:
187
+ raise HTTPException(status_code=400, detail="Provide either a file or a URL, not both")
188
+
189
+ if file:
190
+ data = await file.read()
191
+ if len(data) > _MAX_UPLOAD_BYTES:
192
+ raise HTTPException(status_code=413, detail=f"File exceeds {_settings.max_upload_mb} MB limit")
193
+ if not data:
194
+ raise HTTPException(status_code=400, detail="Empty file")
195
+ source: Any = data
196
+ else:
197
+ source = url
198
+
199
+ metadata = await get_dataset_info(source)
200
+ system_prompt = get_csv_system_prompt(metadata)
201
+
202
+ messages = [
203
+ {"role": "system", "content": system_prompt},
204
+ {"role": "user", "content": query},
205
+ ]
206
+
207
+ redis = getattr(request.app.state, "redis", None)
208
+ scripts = getattr(request.app.state, "scripts", None)
209
+
210
+ try:
211
+ ai_response = await chat_completion(
212
+ messages=messages,
213
+ response_format={"type": "json_object"},
214
+ max_tokens=12000,
215
+ redis=redis,
216
+ scripts=scripts,
217
+ )
218
+ except RuntimeError as e:
219
+ raise HTTPException(status_code=502, detail=str(e))
220
+
221
+ parsed = ai_response.get("parsed")
222
+ if not parsed:
223
+ choices = ai_response.get("choices", [])
224
+ content = choices[0].get("message", {}).get("content", "") if choices else ""
225
+ blocks = extract_json_blocks(content)
226
+ if blocks:
227
+ parsed = blocks[0]
228
+ else:
229
+ try:
230
+ parsed = json.loads(content)
231
+ except (json.JSONDecodeError, TypeError):
232
+ pass
233
+
234
+ if not isinstance(parsed, dict):
235
+ return {
236
+ "success": False,
237
+ "message": None,
238
+ "analyze": [],
239
+ "visualizations": [],
240
+ "error": "AI response was not valid JSON",
241
+ }
242
+
243
+ try:
244
+ ai_data = _AIResponse(**parsed)
245
+ except ValidationError as exc:
246
+ return {
247
+ "success": False,
248
+ "message": None,
249
+ "analyze": [],
250
+ "visualizations": [],
251
+ "error": f"AI response failed schema validation: {exc}",
252
+ }
253
+
254
+ message_text = ai_data.message
255
+ has_content = bool(message_text.strip()) if message_text else False
256
+
257
+ analyze_blocks_raw = [b.model_dump() for b in ai_data.analyze]
258
+ viz_blocks_raw = [b.model_dump() for b in ai_data.visualization]
259
+
260
+ exec_result = await execute_csv_chat_blocks(
261
+ source=source,
262
+ analyze_blocks=analyze_blocks_raw,
263
+ viz_blocks=viz_blocks_raw,
264
+ )
265
+
266
+ if not exec_result["success"]:
267
+ return {
268
+ "success": False,
269
+ "message": ai_data.message if has_content else None,
270
+ "analyze": [],
271
+ "visualizations": [],
272
+ "error": exec_result.get("error", "Code execution failed"),
273
+ }
274
+
275
+ results = exec_result.get("results", {})
276
+ raw_analyze = results.get("analyze", [])
277
+ raw_visualizations = results.get("visualization", [])
278
+
279
+ analyze_results: List[Dict[str, Any]] = []
280
+ for i, block in enumerate(ai_data.analyze):
281
+ raw = raw_analyze[i] if i < len(raw_analyze) else {}
282
+ code = block.python_code.strip()
283
+ if not code:
284
+ continue
285
+ analyze_results.append({
286
+ "description": block.description,
287
+ "code": code,
288
+ "success": raw.get("success", False),
289
+ "output": raw.get("output", ""),
290
+ "error": raw.get("error"),
291
+ "execution_time_ms": exec_result["execution_time_ms"],
292
+ })
293
+
294
+ viz_results: List[Dict[str, Any]] = []
295
+ for i, block in enumerate(ai_data.visualization):
296
+ raw = raw_visualizations[i] if i < len(raw_visualizations) else {}
297
+ code = block.python_code.strip()
298
+ if not code:
299
+ continue
300
+ viz_results.append({
301
+ "description": block.description,
302
+ "code": code,
303
+ "success": raw.get("success", False),
304
+ "image_base64": raw.get("image_base64"),
305
+ "error": raw.get("error"),
306
+ "execution_time_ms": exec_result["execution_time_ms"],
307
+ })
308
+
309
+ return {
310
+ "success": True,
311
+ "message": ai_data.message if has_content else None,
312
+ "analyze": analyze_results,
313
+ "visualizations": viz_results,
314
+ "error": None,
315
+ }
app/api/v1/router.py CHANGED
@@ -2,7 +2,7 @@ from __future__ import annotations
2
 
3
  from fastapi import APIRouter
4
 
5
- from app.api.v1 import auth, batch, chat, code_executor, convert, database, embeddings, qr_generator, reconcile, scraper, semantic_router, sql_validator, system, token_counter, token_generator, url_shortener, vector_stores, web_search, webhook_socket
6
  from app.api.verify import router as verify_router
7
 
8
  api_v1_router = APIRouter()
@@ -24,5 +24,6 @@ api_v1_router.include_router(token_generator.router, tags=["Token Generator"])
24
  api_v1_router.include_router(chat.router, tags=["Chat"])
25
  api_v1_router.include_router(vector_stores.router, tags=["Vector Stores"])
26
  api_v1_router.include_router(webhook_socket.router, tags=["Webhook / Socket"])
 
27
  api_v1_router.include_router(qr_generator.router, tags=["QR Generator"])
28
  api_v1_router.include_router(url_shortener.router, tags=["URL Shortener"])
 
2
 
3
  from fastapi import APIRouter
4
 
5
+ from app.api.v1 import auth, batch, chat, code_executor, convert, csv_analysis, database, embeddings, qr_generator, reconcile, scraper, semantic_router, sql_validator, system, token_counter, token_generator, url_shortener, vector_stores, web_search, webhook_socket
6
  from app.api.verify import router as verify_router
7
 
8
  api_v1_router = APIRouter()
 
24
  api_v1_router.include_router(chat.router, tags=["Chat"])
25
  api_v1_router.include_router(vector_stores.router, tags=["Vector Stores"])
26
  api_v1_router.include_router(webhook_socket.router, tags=["Webhook / Socket"])
27
+ api_v1_router.include_router(csv_analysis.router, tags=["CSV Analysis"])
28
  api_v1_router.include_router(qr_generator.router, tags=["QR Generator"])
29
  api_v1_router.include_router(url_shortener.router, tags=["URL Shortener"])
app/services/csv_analysis_service.py ADDED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import signal
10
+ import sys
11
+ import tempfile
12
+ import time
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List, Optional, Tuple, Union
15
+ from urllib.parse import unquote, urlparse
16
+
17
+ import aiohttp
18
+
19
+ from app.services.code_executor_service import CodeSanitizer
20
+ from app.services.dataset_metadata_service import extract_metadata
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ _PYTHON = getattr(sys, "executable", None) or "python3"
25
+ _MAX_OUTPUT_BYTES = 65536
26
+ _MAX_CONCURRENT = 8
27
+ _DOWNLOAD_CHUNK_SIZE = 256 * 1024
28
+ _DOWNLOAD_TIMEOUT = 120
29
+
30
+ _semaphore = asyncio.Semaphore(_MAX_CONCURRENT)
31
+
32
+
33
+ class CSVAnalysisError(Exception):
34
+ pass
35
+
36
+
37
+ async def _download_file(url: str) -> bytes:
38
+ timeout = aiohttp.ClientTimeout(total=_DOWNLOAD_TIMEOUT)
39
+ try:
40
+ async with aiohttp.ClientSession(timeout=timeout) as session:
41
+ async with session.get(url) as resp:
42
+ if resp.status != 200:
43
+ raise CSVAnalysisError(f"HTTP {resp.status} when fetching {url}")
44
+ chunks: List[bytes] = []
45
+ async for chunk in resp.content.iter_chunked(_DOWNLOAD_CHUNK_SIZE):
46
+ chunks.append(chunk)
47
+ return b"".join(chunks)
48
+ except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
49
+ raise CSVAnalysisError(f"Download failed for {url}: {exc}") from exc
50
+
51
+
52
+ async def _resolve_source(source: Union[str, bytes]) -> Tuple[bytes, Optional[str]]:
53
+ if isinstance(source, str) and source.lower().startswith(("http://", "https://")):
54
+ data = await _download_file(source)
55
+ parsed = urlparse(source)
56
+ filename = unquote(Path(parsed.path).name) if parsed.path else None
57
+ return data, filename
58
+ elif isinstance(source, bytes):
59
+ return source, None
60
+ else:
61
+ raise TypeError(f"Unsupported source type: {type(source)}")
62
+
63
+
64
+ def _run_subprocess(cmd: List[str], timeout: float, max_output: int) -> Dict[str, Any]:
65
+ proc = subprocess.Popen(
66
+ cmd,
67
+ stdin=subprocess.DEVNULL,
68
+ stdout=subprocess.PIPE,
69
+ stderr=subprocess.PIPE,
70
+ )
71
+ try:
72
+ stdout_bytes, stderr_bytes = proc.communicate(timeout=timeout)
73
+ timed_out = False
74
+ except subprocess.TimeoutExpired:
75
+ try:
76
+ if os.name == "nt":
77
+ proc.kill()
78
+ else:
79
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
80
+ except Exception:
81
+ proc.kill()
82
+ stdout_bytes, stderr_bytes = proc.communicate()
83
+ timed_out = True
84
+
85
+ return {
86
+ "stdout": (stdout_bytes.decode("utf-8", errors="replace")[:max_output] if stdout_bytes else ""),
87
+ "stderr": (stderr_bytes.decode("utf-8", errors="replace")[:max_output] if stderr_bytes else ""),
88
+ "exit_code": proc.returncode,
89
+ "timed_out": timed_out,
90
+ }
91
+
92
+
93
+ _CHAT_SCRIPT = """\
94
+ import json, sys, io, base64, traceback
95
+ import pandas as pd, numpy as np
96
+ import matplotlib
97
+ matplotlib.use("Agg")
98
+ import matplotlib.pyplot as plt
99
+ import seaborn as sns
100
+
101
+ df = pd.read_csv(r"{csv_path}")
102
+
103
+ with open(r"{blocks_path}", "r") as _f:
104
+ _data = json.load(_f)
105
+
106
+ _results = {{"analyze": [], "visualization": []}}
107
+ _ns = {{"df": df, "pd": pd, "np": np, "plt": plt, "sns": sns}}
108
+
109
+ for _b in _data.get("analyze", []):
110
+ _code = (_b.get("python_code") or "").strip()
111
+ if not _code:
112
+ _results["analyze"].append({{"success": True, "output": "", "error": None}})
113
+ continue
114
+ _old = sys.stdout
115
+ sys.stdout = io.StringIO()
116
+ try:
117
+ exec(_code, _ns)
118
+ _results["analyze"].append({{"success": True, "output": sys.stdout.getvalue(), "error": None}})
119
+ except Exception:
120
+ _results["analyze"].append({{"success": False, "output": sys.stdout.getvalue(), "error": traceback.format_exc()}})
121
+ finally:
122
+ sys.stdout = _old
123
+
124
+ for _b in _data.get("visualization", []):
125
+ _code = (_b.get("python_code") or "").strip()
126
+ if not _code:
127
+ _results["visualization"].append({{"success": True, "image_base64": "", "error": None}})
128
+ continue
129
+ _full = _code + (
130
+ "\\nfrom io import BytesIO\\nimport base64\\n"
131
+ "_buf = BytesIO()\\nplt.savefig(_buf, format='png', bbox_inches='tight', dpi=150)\\n"
132
+ "_buf.seek(0)\\nprint(base64.b64encode(_buf.read()).decode(), end='')\\n"
133
+ "plt.close('all')\\n"
134
+ )
135
+ _old = sys.stdout
136
+ sys.stdout = io.StringIO()
137
+ try:
138
+ exec(_full, _ns)
139
+ _results["visualization"].append({{"success": True, "image_base64": sys.stdout.getvalue().strip(), "error": None}})
140
+ except Exception:
141
+ _results["visualization"].append({{"success": False, "image_base64": None, "error": traceback.format_exc()}})
142
+ finally:
143
+ sys.stdout = _old
144
+ plt.close("all")
145
+
146
+ print(json.dumps(_results))
147
+ """
148
+
149
+
150
+ async def execute_csv_chat_blocks(
151
+ source: Union[str, bytes],
152
+ analyze_blocks: List[Dict[str, Any]],
153
+ viz_blocks: List[Dict[str, Any]],
154
+ timeout: int = 60,
155
+ ) -> Dict[str, Any]:
156
+ data, _ = await _resolve_source(source)
157
+ if not data:
158
+ return {"success": False, "results": None, "error": "No data provided"}
159
+
160
+ async with _semaphore:
161
+ run_dir = None
162
+ start = time.monotonic()
163
+ try:
164
+ run_dir = Path(tempfile.mkdtemp())
165
+ csv_path = run_dir / "data.csv"
166
+ csv_path.write_bytes(data)
167
+
168
+ blocks_path = run_dir / "blocks.json"
169
+ blocks_path.write_text(
170
+ json.dumps({"analyze": analyze_blocks, "visualization": viz_blocks}),
171
+ encoding="utf-8",
172
+ )
173
+
174
+ script = _CHAT_SCRIPT.format(
175
+ csv_path=csv_path.as_posix(),
176
+ blocks_path=blocks_path.as_posix(),
177
+ )
178
+
179
+ script_path = run_dir / "chat_exec.py"
180
+ script_path.write_text(script, encoding="utf-8")
181
+
182
+ cmd = [_PYTHON, str(script_path)]
183
+ result = await asyncio.to_thread(_run_subprocess, cmd, timeout, _MAX_OUTPUT_BYTES)
184
+ elapsed_ms = (time.monotonic() - start) * 1000
185
+
186
+ if result["exit_code"] != 0:
187
+ return {
188
+ "success": False,
189
+ "results": None,
190
+ "error": result["stderr"] or "Subprocess failed",
191
+ "execution_time_ms": round(elapsed_ms, 2),
192
+ }
193
+
194
+ parsed = json.loads(result["stdout"])
195
+ return {
196
+ "success": True,
197
+ "results": parsed,
198
+ "error": None,
199
+ "execution_time_ms": round(elapsed_ms, 2),
200
+ }
201
+ except json.JSONDecodeError as exc:
202
+ elapsed_ms = (time.monotonic() - start) * 1000
203
+ return {"success": False, "results": None, "error": f"Failed to parse output: {exc}", "execution_time_ms": round(elapsed_ms, 2)}
204
+ except FileNotFoundError:
205
+ elapsed_ms = (time.monotonic() - start) * 1000
206
+ return {"success": False, "results": None, "error": f"Python runtime ({_PYTHON}) not found", "execution_time_ms": round(elapsed_ms, 2)}
207
+ except Exception as exc:
208
+ elapsed_ms = (time.monotonic() - start) * 1000
209
+ logger.exception("CSV chat execution error")
210
+ return {"success": False, "results": None, "error": f"Execution error: {exc}", "execution_time_ms": round(elapsed_ms, 2)}
211
+ finally:
212
+ if run_dir and run_dir.exists():
213
+ shutil.rmtree(run_dir, ignore_errors=True)
214
+
215
+
216
+ async def get_dataset_info(source: Union[str, bytes, Any]) -> Dict[str, Any]:
217
+ return await extract_metadata(source)
218
+
219
+
220
+ async def analyze_csv_dataset(
221
+ source: Union[str, bytes],
222
+ code: str,
223
+ timeout: int = 30,
224
+ ) -> Dict[str, Any]:
225
+ data, _ = await _resolve_source(source)
226
+
227
+ if not data:
228
+ return {"success": False, "output": "", "error": "No data provided", "execution_time_ms": None}
229
+
230
+ sanitized, err = CodeSanitizer.sanitize(code, "python")
231
+ if not sanitized:
232
+ return {"success": False, "output": "", "error": err, "execution_time_ms": None}
233
+
234
+ async with _semaphore:
235
+ run_dir = None
236
+ start = time.monotonic()
237
+ try:
238
+ run_dir = Path(tempfile.mkdtemp())
239
+ csv_path = run_dir / "data.csv"
240
+ csv_path.write_bytes(data)
241
+
242
+ loader = (
243
+ "import pandas as pd, numpy as np\n"
244
+ f"df = pd.read_csv(r'{csv_path}')\n"
245
+ )
246
+ full_code = loader + code
247
+
248
+ code_path = run_dir / "analysis.py"
249
+ code_path.write_text(full_code, encoding="utf-8")
250
+
251
+ cmd = [_PYTHON, str(code_path)]
252
+ result = await asyncio.to_thread(_run_subprocess, cmd, timeout, _MAX_OUTPUT_BYTES)
253
+
254
+ elapsed_ms = (time.monotonic() - start) * 1000
255
+
256
+ return {
257
+ "success": result["exit_code"] == 0 and not result["timed_out"],
258
+ "output": result["stdout"],
259
+ "error": result["stderr"] or None,
260
+ "execution_time_ms": round(elapsed_ms, 2),
261
+ "timed_out": result["timed_out"],
262
+ }
263
+
264
+ except FileNotFoundError:
265
+ elapsed_ms = (time.monotonic() - start) * 1000
266
+ return {"success": False, "output": "", "error": f"Python runtime ({_PYTHON}) not found", "execution_time_ms": round(elapsed_ms, 2)}
267
+ except Exception as exc:
268
+ elapsed_ms = (time.monotonic() - start) * 1000
269
+ logger.exception("CSV analysis execution error")
270
+ return {"success": False, "output": "", "error": f"Execution error: {exc}", "execution_time_ms": round(elapsed_ms, 2)}
271
+ finally:
272
+ if run_dir and run_dir.exists():
273
+ shutil.rmtree(run_dir, ignore_errors=True)
274
+
275
+
276
+ async def create_csv_chart(
277
+ source: Union[str, bytes],
278
+ code: str,
279
+ timeout: int = 30,
280
+ ) -> Dict[str, Any]:
281
+ preamble = (
282
+ "import matplotlib\nmatplotlib.use('Agg')\n"
283
+ "import matplotlib.pyplot as plt\nimport seaborn as sns\nimport pandas as pd\n"
284
+ )
285
+ postamble = (
286
+ "\n\nfrom io import BytesIO\nimport base64\n"
287
+ "_buf = BytesIO()\n"
288
+ "plt.savefig(_buf, format='png', bbox_inches='tight', dpi=150)\n"
289
+ "_buf.seek(0)\n"
290
+ "print(base64.b64encode(_buf.read()).decode(), end='')\n"
291
+ "plt.close('all')\n"
292
+ )
293
+
294
+ full_code = preamble + code + postamble
295
+ result = await analyze_csv_dataset(source, full_code, timeout)
296
+
297
+ if result["success"]:
298
+ return {"success": True, "image_base64": result.get("output", "") or "", "error": None, "execution_time_ms": result["execution_time_ms"]}
299
+ return {"success": False, "image_base64": None, "error": result.get("error") or "Chart generation failed or produced no output", "execution_time_ms": result["execution_time_ms"]}
app/services/prompts/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from app.services.prompts.csv_system_prompt import get_csv_system_prompt
2
+
3
+ __all__ = ["get_csv_system_prompt"]
app/services/prompts/csv_system_prompt.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from typing import Any, Dict
6
+
7
+ _JSON_EXAMPLE = (
8
+ '```json\n'
9
+ '{\n'
10
+ ' "analyze": [\n'
11
+ ' {\n'
12
+ ' "description": "Short explanation of the math",\n'
13
+ ' "python_code": "# Clean data first\\ndf[\'col\'] = ...\\n\\n# Perform analysis\\nresult = df.groupby..."\n'
14
+ ' }\n'
15
+ ' ],\n'
16
+ ' "visualization": [\n'
17
+ ' {\n'
18
+ ' "description": "Short explanation of the chart",\n'
19
+ ' "python_code": "# Clean data first\\ndf[\'col\'] = ...\\n\\n# Plot\\nplt.figure(figsize=(12,6))\\nsns.barplot(data=df, ...)"\n'
20
+ ' }\n'
21
+ ' ],\n'
22
+ ' "message": "Fill this ONLY if the user is greeting, asking non-data questions or asking for wrong information."\n'
23
+ '}\n'
24
+ '```'
25
+ )
26
+
27
+
28
+ def get_csv_system_prompt(metadata: Dict[str, Any]) -> str:
29
+ shape = metadata.get("shape", {})
30
+ num_rows = shape.get("rows", "?")
31
+ num_cols = shape.get("columns", "?")
32
+ columns = metadata.get("columns", [])
33
+ dtypes = metadata.get("dtypes", {})
34
+ sample_data = metadata.get("sample_data", [])
35
+ numeric_cols = metadata.get("numeric_columns", [])
36
+ categorical_cols = metadata.get("categorical_columns", [])
37
+
38
+ columns_str = ", ".join(columns)
39
+ dtypes_str = json.dumps(dtypes)
40
+ sample_str = json.dumps(sample_data[:1], indent=2) if sample_data else "[]"
41
+ numeric_str = ", ".join(numeric_cols) if numeric_cols else "None"
42
+ categorical_str = ", ".join(categorical_cols) if categorical_cols else "None"
43
+
44
+ info_block = (
45
+ f"CSV Info:\n"
46
+ f"- Shape: {num_rows} rows x {num_cols} cols\n"
47
+ f"- Columns: {columns_str}\n"
48
+ f"- Sample Data: {sample_str}\n"
49
+ f"- Data Types: {dtypes_str}\n"
50
+ f"- Numeric Columns: {numeric_str}\n"
51
+ f"- Categorical Columns: {categorical_str}\n"
52
+ )
53
+
54
+ prompt = f"""\
55
+ You are a Senior Data Analyst AI and CSV analysis assistant. Your goal is to extract actionable insights, perform statistical analysis, answer complex questions, and generate professional visualizations using the provided dataset.
56
+
57
+ The pandas DataFrame is pre-loaded as 'df' - use this variable.
58
+
59
+ {info_block}\
60
+ STRICT OPERATIONAL REQUIREMENTS:
61
+ 1. NEVER guess, predict, or estimate values yourself. ALWAYS generate executable Python code to calculate precise answers.
62
+ 2. USE THE EXISTING 'df' - Do not attempt to reload or recreate the dataframe.
63
+ 3. VARIABLE ASSIGNMENT IS MANDATORY: Every result, calculation, filtered subset, or visualization must be assigned to a descriptive, snake_case variable name.
64
+ 4. JSON FOR STRUCTURED DATA: For any data structures (Lists, Records, Tables, Dictionaries, etc.), return them as JSON with correct indentation so the UI can parse it.
65
+ 5. CLEANLINESS: If the analysis requires handling missing values (NaNs) or data cleaning, perform it on a copy (e.g., 'cleaned_df') before analyzing.
66
+
67
+ ANALYSIS GUIDELINES:
68
+ - Descriptive Statistics: Use .describe(), .value_counts(), and .nunique().
69
+ - Relationships: Calculate correlations using .corr() or group data using .groupby().
70
+ - Filtering: Always store filtered results in a specific variable (e.g., 'high_value_customers = ...').
71
+ - Aggregation: When grouping, reset indices (.reset_index()) to keep results in a flat, readable format.
72
+ - Outliers: Use IQR or Z-score methods when asked to find anomalies.
73
+
74
+ VISUALIZATION STANDARDS:
75
+ - Use matplotlib/seaborn only.
76
+ - Professional quality: proper sizing, labels, titles.
77
+ - Figure size: (14, 8) for complex charts, (12, 6) for simple charts.
78
+ - Fonts: Clear titles (fontsize=16), labels (fontsize=14).
79
+ - Ticks: Rotate x-labels if needed (45 degree), fontsize=12.
80
+ - Aesthetics: Add annotations/gridlines where helpful; use colorblind-friendly palettes.
81
+ - Final Step: Always include plt.tight_layout() and plt.show().
82
+ - Variable Assignment: Assign figure/axis objects when needed (e.g., fig, ax = plt.subplots...).
83
+
84
+ VARIABLE ASSIGNMENT RULES:
85
+ 1. Every operation must store its result in a variable.
86
+ 2. Variable names should be descriptive and snake_case.
87
+ 3. For DataFrame operations: result_df = df.operation()
88
+ 4. For statistical results: summary_stats = df.describe(include='all')
89
+ 5. For filtered data: filtered_data = df[df['column'] > value]
90
+ 6. For grouped analysis: revenue_by_region = df.groupby('region')['revenue'].sum().reset_index()
91
+ 7. For correlation matrices: correlation_matrix = df.corr(numeric_only=True)
92
+ 8. For visualizations: fig, ax = plt.subplots(...)
93
+
94
+ EXAMPLES:
95
+
96
+ 1. Professional Chart (with variable assignment):
97
+ fig, ax = plt.subplots(figsize=(14, 8))
98
+ sns.barplot(x='category', y='value', data=df, palette='muted', ax=ax)
99
+ ax.set_title('Value by Category', fontsize=16)
100
+ ax.set_xlabel('Category', fontsize=14)
101
+ ax.set_ylabel('Value', fontsize=14)
102
+ ax.set_xticklabels(ax.get_xticklabels(), rotation=45)
103
+ ax.grid(alpha=0.3)
104
+ plt.tight_layout()
105
+ plt.show()
106
+
107
+ 2. Professional Analysis (Clean, Assigned, Modular):
108
+ # Calculate the percentage of missing values per column
109
+ missing_data_report = df.isnull().mean() * 100
110
+
111
+ # Identify top 5 performing categories by sales
112
+ top_categories_sales = df.groupby('category')['sales'].sum().nlargest(5).reset_index()
113
+
114
+ # Check for correlation between price and quantity
115
+ price_quantity_corr = df['price'].corr(df['quantity'])
116
+
117
+ 3. Good vs Bad (Assignment Check):
118
+ # GOOD (with variable assignment)
119
+ sample_transactions = df.sample(5)[['id', 'date', 'amount']]
120
+ transaction_stats = df['amount'].describe()
121
+
122
+ # BAD (no variable assignment)
123
+ df.sample(5)[['id', 'date', 'amount']] # No variable assigned!
124
+
125
+ Return complete, executable code that follows these rules.
126
+ Your response should be modular, precise, and favor variable assignment over direct printing.
127
+
128
+ ### 2. STRICT OUTPUT FORMAT
129
+ Return your response ONLY as a JSON object.
130
+
131
+ - **If the user asks for analysis/charts:** Fill "analyze" and "visualization" arrays with Python code.
132
+ - **If the user greets you or asks a generic question:** Use the "message" field for your response and keep the arrays empty.
133
+
134
+ {_JSON_EXAMPLE}"""
135
+
136
+ return prompt.strip()
137
+
138
+
139
+ # if __name__ == "__main__":
140
+ # import asyncio
141
+ # import sys
142
+ # _root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
143
+ # sys.path.insert(0, _root)
144
+ # from app.services.csv_analysis_service import get_dataset_info
145
+
146
+ # url = "https://raw.githubusercontent.com/datasciencedojo/datasets/master/titanic.csv"
147
+ # metadata = asyncio.run(get_dataset_info(url))
148
+ # prompt = get_csv_system_prompt(metadata)
149
+ # print(prompt)
150
+ # print()
151
+ # print(f"(length: {len(prompt)} chars)")
pyproject.toml CHANGED
@@ -27,6 +27,8 @@ dependencies = [
27
  "pillow>=10.0.0",
28
  "pypdfium2>=4.30.0",
29
  "pandas>=2.0.0",
 
 
30
  "spacy>=3.7.0",
31
  "phonenumbers>=8.13.0",
32
  ]
 
27
  "pillow>=10.0.0",
28
  "pypdfium2>=4.30.0",
29
  "pandas>=2.0.0",
30
+ "matplotlib>=3.8.0",
31
+ "seaborn>=0.13.0",
32
  "spacy>=3.7.0",
33
  "phonenumbers>=8.13.0",
34
  ]
requirements.txt CHANGED
@@ -12,6 +12,8 @@ onnxruntime>=1.18.0
12
  pillow>=10.0.0
13
  pypdfium2>=4.30.0
14
  pandas>=2.0.0
 
 
15
  sentence-transformers==5.6.0
16
 
17
  aiohttp>=3.9.0
 
12
  pillow>=10.0.0
13
  pypdfium2>=4.30.0
14
  pandas>=2.0.0
15
+ matplotlib>=3.8.0
16
+ seaborn>=0.13.0
17
  sentence-transformers==5.6.0
18
 
19
  aiohttp>=3.9.0