Spaces:
Sleeping
Sleeping
Download main.py from Mvuyala/data_analyst_agent: direct link, hf CLI and curl.
- Browser
- Download file 27 kB
-
https://huggingface.co/spaces/Mvuyala/data_analyst_agent/resolve/main/main.py
- Command line
-
hf download hf://spaces/Mvuyala/data_analyst_agent/main.py
-
curl -L -o main.py https://huggingface.co/spaces/Mvuyala/data_analyst_agent/resolve/main/main.py
27 kB
| from __future__ import annotations | |
| import asyncio | |
| import base64 | |
| import io | |
| import json | |
| import math | |
| import os | |
| import re | |
| import subprocess | |
| import sys | |
| import tempfile | |
| import textwrap | |
| import time | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| from typing import Any | |
| import numpy as np | |
| import pandas as pd | |
| import requests | |
| from bs4 import BeautifulSoup | |
| from fastapi import FastAPI, HTTPException, Request | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import JSONResponse | |
| from openai import OpenAI | |
| OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4.1-mini") | |
| MAX_UPLOAD_BYTES = int(os.getenv("MAX_UPLOAD_BYTES", "30000000")) | |
| REQUEST_TIMEOUT_SECONDS = int(os.getenv("REQUEST_TIMEOUT_SECONDS", "260")) | |
| CODE_TIMEOUT_SECONDS = int(os.getenv("CODE_TIMEOUT_SECONDS", "210")) | |
| MAX_CONTEXT_CHARS = int(os.getenv("MAX_CONTEXT_CHARS", "18000")) | |
| USER_AGENT = os.getenv( | |
| "DATA_AGENT_USER_AGENT", | |
| "data-analyst-agent/1.0 (+https://huggingface.co/spaces/Mvuyala/data_analyst_agent)", | |
| ) | |
| app = FastAPI(title="Data Analyst Agent", version="1.0.0") | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_credentials=False, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| class UploadedAsset: | |
| field_name: str | |
| filename: str | |
| path: Path | |
| content_type: str | None | |
| size: int | |
| async def root() -> dict[str, Any]: | |
| return { | |
| "name": "Data Analyst Agent", | |
| "status": "ok", | |
| "endpoints": { | |
| "api": "POST /api/", | |
| "health": "GET /health", | |
| }, | |
| } | |
| async def health() -> dict[str, str]: | |
| return {"status": "ok"} | |
| async def api(request: Request) -> JSONResponse: | |
| started_at = time.monotonic() | |
| with tempfile.TemporaryDirectory(prefix="data-agent-") as tmp: | |
| workdir = Path(tmp) | |
| questions, assets = await collect_inputs(request, workdir) | |
| result = await answer_questions(questions, assets, workdir, started_at) | |
| return JSONResponse(content=to_jsonable(result)) | |
| async def answer_questions_legacy(request: Request) -> JSONResponse: | |
| return await api(request) | |
| async def collect_inputs(request: Request, workdir: Path) -> tuple[str, list[UploadedAsset]]: | |
| try: | |
| form = await request.form() | |
| except Exception as exc: | |
| raise HTTPException(status_code=400, detail=f"Invalid multipart form data: {exc}") from exc | |
| questions: str | None = None | |
| assets: list[UploadedAsset] = [] | |
| for field_name, value in form.multi_items(): | |
| if is_upload(value): | |
| raw = await value.read() | |
| filename = value.filename or field_name | |
| if len(raw) > MAX_UPLOAD_BYTES: | |
| raise HTTPException( | |
| status_code=413, | |
| detail=f"{filename} is larger than {MAX_UPLOAD_BYTES} bytes.", | |
| ) | |
| if is_questions_file(field_name, filename): | |
| questions = decode_text(raw) | |
| continue | |
| saved_path = unique_upload_path(workdir, filename) | |
| saved_path.write_bytes(raw) | |
| assets.append( | |
| UploadedAsset( | |
| field_name=field_name, | |
| filename=filename, | |
| path=saved_path, | |
| content_type=getattr(value, "content_type", None), | |
| size=len(raw), | |
| ) | |
| ) | |
| elif field_name.lower() in {"question", "questions", "questions.txt", "prompt", "task"} and questions is None: | |
| questions = str(value) | |
| if not questions or not questions.strip(): | |
| raise HTTPException( | |
| status_code=400, | |
| detail='Send a multipart field named "questions.txt" containing the analysis task.', | |
| ) | |
| return questions.strip(), assets | |
| async def answer_questions( | |
| questions: str, | |
| assets: list[UploadedAsset], | |
| workdir: Path, | |
| started_at: float, | |
| ) -> Any: | |
| known = await try_known_task(questions) | |
| if known is not None: | |
| return known | |
| return await run_openai_code_agent(questions, assets, workdir, started_at) | |
| async def try_known_task(questions: str) -> Any | None: | |
| lowered = questions.lower() | |
| if "highest-grossing films" in lowered and "wikipedia" in lowered: | |
| try: | |
| return await asyncio.to_thread(answer_highest_grossing_films, questions) | |
| except Exception: | |
| return None | |
| if "indian high court" in lowered and "indian-high-court-judgments" in lowered: | |
| try: | |
| return await asyncio.to_thread(answer_indian_high_court, questions) | |
| except Exception: | |
| return None | |
| return None | |
| def answer_highest_grossing_films(questions: str) -> list[Any]: | |
| urls = extract_urls(questions) | |
| url = next( | |
| (u for u in urls if "List_of_highest-grossing_films" in u), | |
| "https://en.wikipedia.org/wiki/List_of_highest-grossing_films", | |
| ) | |
| response = requests.get(url, headers={"User-Agent": USER_AGENT}, timeout=30) | |
| response.raise_for_status() | |
| tables = pd.read_html(io.StringIO(response.text)) | |
| table = find_films_table(tables) | |
| clean = pd.DataFrame( | |
| { | |
| "rank": clean_number_series(table[find_column(table, r"^rank$")]), | |
| "peak": clean_number_series(table[find_column(table, r"^peak$")]), | |
| "title": table[find_column(table, r"title|film")].map(clean_title), | |
| "year": clean_year_series(table[find_column(table, r"year|release")]), | |
| "gross": table[find_column(table, r"gross|worldwide")].map(parse_money), | |
| } | |
| ).dropna(subset=["rank", "peak", "year", "gross"]) | |
| over_2bn_before_2000 = int(((clean["gross"] >= 2_000_000_000) & (clean["year"] < 2000)).sum()) | |
| earliest = ( | |
| clean.loc[clean["gross"] > 1_500_000_000] | |
| .sort_values(["year", "rank"], ascending=True) | |
| .iloc[0]["title"] | |
| ) | |
| correlation = float(clean[["rank", "peak"]].corr().iloc[0, 1]) | |
| plot = scatter_with_regression_uri(clean["rank"], clean["peak"], "Rank", "Peak") | |
| return [over_2bn_before_2000, earliest, round(correlation, 6), plot] | |
| def answer_indian_high_court(questions: str) -> dict[str, Any]: | |
| import duckdb | |
| source = ( | |
| "s3://indian-high-court-judgments/metadata/parquet/" | |
| "year=*/court=*/bench=*/metadata.parquet?s3_region=ap-south-1" | |
| ) | |
| con = duckdb.connect(database=":memory:") | |
| con.execute("INSTALL httpfs; LOAD httpfs; INSTALL parquet; LOAD parquet;") | |
| most_cases = con.execute( | |
| f""" | |
| SELECT court, COUNT(*) AS cases | |
| FROM read_parquet('{source}') | |
| WHERE year BETWEEN 2019 AND 2022 | |
| GROUP BY court | |
| ORDER BY cases DESC | |
| LIMIT 1 | |
| """ | |
| ).fetchone()[0] | |
| delay_by_year = con.execute( | |
| f""" | |
| SELECT | |
| year, | |
| AVG(date_diff('day', strptime(date_of_registration, '%d-%m-%Y'), decision_date)) AS delay_days | |
| FROM read_parquet('{source}') | |
| WHERE court = '33_10' | |
| AND date_of_registration IS NOT NULL | |
| AND decision_date IS NOT NULL | |
| GROUP BY year | |
| ORDER BY year | |
| """ | |
| ).df() | |
| delay_by_year = delay_by_year.dropna() | |
| slope = float(np.polyfit(delay_by_year["year"], delay_by_year["delay_days"], 1)[0]) | |
| plot = scatter_with_regression_uri( | |
| delay_by_year["year"], | |
| delay_by_year["delay_days"], | |
| "Year", | |
| "Average delay days", | |
| ) | |
| keys = extract_requested_object_keys(questions) | |
| default_keys = [ | |
| "Which high court disposed the most cases from 2019 - 2022?", | |
| "What's the regression slope of the date_of_registration - decision_date by year in the court=33_10?", | |
| "Plot the year and # of days of delay from the above question as a scatterplot with a regression line. Encode as a base64 data URI under 100,000 characters", | |
| ] | |
| keys = keys or default_keys | |
| values = [most_cases, round(slope, 6), plot] | |
| return {key: values[index] if index < len(values) else None for index, key in enumerate(keys)} | |
| async def run_openai_code_agent( | |
| questions: str, | |
| assets: list[UploadedAsset], | |
| workdir: Path, | |
| started_at: float, | |
| ) -> Any: | |
| if not os.getenv("OPENAI_API_KEY"): | |
| return fallback_response(questions, "OPENAI_API_KEY is not configured.") | |
| try: | |
| inventory = await asyncio.to_thread(build_inventory, assets) | |
| url_context = await asyncio.to_thread(summarize_urls, extract_urls(questions)) | |
| prompt = build_code_prompt(questions, inventory, url_context) | |
| code = await asyncio.to_thread(request_code, prompt) | |
| last_error = "" | |
| for attempt in range(2): | |
| remaining = max(20, int(REQUEST_TIMEOUT_SECONDS - (time.monotonic() - started_at))) | |
| timeout = min(CODE_TIMEOUT_SECONDS, remaining) | |
| result, error = await asyncio.to_thread(run_generated_code, code, workdir, timeout) | |
| if error is None: | |
| return result | |
| last_error = error | |
| if attempt == 0 and time.monotonic() - started_at < REQUEST_TIMEOUT_SECONDS - 45: | |
| repair_prompt = build_repair_prompt(questions, inventory, url_context, code, error) | |
| code = await asyncio.to_thread(request_code, repair_prompt) | |
| return fallback_response(questions, last_error) | |
| except Exception as exc: | |
| return fallback_response(questions, str(exc)) | |
| def request_code(prompt: str) -> str: | |
| client = OpenAI(timeout=60) | |
| response = client.chat.completions.create( | |
| model=OPENAI_MODEL, | |
| temperature=0, | |
| messages=[ | |
| { | |
| "role": "system", | |
| "content": ( | |
| "You are a senior data analyst. Return only executable Python code. " | |
| "The code must compute the answer and print exactly one JSON value." | |
| ), | |
| }, | |
| {"role": "user", "content": prompt}, | |
| ], | |
| ) | |
| content = response.choices[0].message.content or "" | |
| return extract_code(content) | |
| def run_generated_code(code: str, workdir: Path, timeout: int) -> tuple[Any | None, str | None]: | |
| helper_path = workdir / "analysis_helpers.py" | |
| helper_path.write_text(ANALYSIS_HELPERS, encoding="utf-8") | |
| script_path = workdir / "analysis_task.py" | |
| script_path.write_text(code, encoding="utf-8") | |
| env = os.environ.copy() | |
| env["PYTHONIOENCODING"] = "utf-8" | |
| for key in list(env): | |
| upper = key.upper() | |
| if "TOKEN" in upper or "SECRET" in upper or "API_KEY" in upper: | |
| env.pop(key, None) | |
| try: | |
| proc = subprocess.run( | |
| [sys.executable, str(script_path)], | |
| cwd=workdir, | |
| capture_output=True, | |
| text=True, | |
| timeout=timeout, | |
| env=env, | |
| ) | |
| except subprocess.TimeoutExpired as exc: | |
| return None, f"Generated analysis timed out after {timeout}s. stdout={exc.stdout!r} stderr={exc.stderr!r}" | |
| stdout = proc.stdout.strip() | |
| stderr = proc.stderr.strip() | |
| if proc.returncode != 0: | |
| return None, f"Generated analysis exited with {proc.returncode}.\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}" | |
| try: | |
| return parse_json_from_stdout(stdout), None | |
| except Exception as exc: | |
| return None, f"Could not parse JSON from generated analysis output: {exc}\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}" | |
| def build_code_prompt(questions: str, inventory: list[dict[str, Any]], url_context: list[dict[str, Any]]) -> str: | |
| return textwrap.dedent( | |
| f""" | |
| You are running inside a temporary working directory. Uploaded files are already saved there. | |
| Task from questions.txt: | |
| {questions} | |
| Uploaded file inventory: | |
| {truncate(json.dumps(inventory, indent=2, default=str), MAX_CONTEXT_CHARS)} | |
| URL/table context gathered before code generation: | |
| {truncate(json.dumps(url_context, indent=2, default=str), MAX_CONTEXT_CHARS)} | |
| Write Python 3.10 code that completes the task exactly. | |
| Rules: | |
| - Use pandas, numpy, requests, bs4, duckdb, matplotlib, seaborn, and standard library as needed. | |
| - Use the exact output format requested in questions.txt: JSON array, object, string, or number. | |
| - For charts, return base64 data URIs and keep them under 100,000 bytes when requested. | |
| - You may import from analysis_helpers: image_to_data_uri, print_json, clean_number. | |
| - Print exactly one JSON value by calling print_json(result) as the final statement. | |
| - Do not read environment variables or secrets. | |
| - Do not include Markdown fences or explanation. | |
| """ | |
| ).strip() | |
| def build_repair_prompt( | |
| questions: str, | |
| inventory: list[dict[str, Any]], | |
| url_context: list[dict[str, Any]], | |
| code: str, | |
| error: str, | |
| ) -> str: | |
| return textwrap.dedent( | |
| f""" | |
| Repair the Python code for this data analysis task. | |
| Task from questions.txt: | |
| {questions} | |
| Uploaded file inventory: | |
| {truncate(json.dumps(inventory, indent=2, default=str), MAX_CONTEXT_CHARS)} | |
| URL/table context: | |
| {truncate(json.dumps(url_context, indent=2, default=str), MAX_CONTEXT_CHARS)} | |
| Previous code: | |
| {code} | |
| Failure: | |
| {truncate(error, 6000)} | |
| Return only corrected executable Python code. It must print exactly one JSON value | |
| with print_json(result) as the final statement. | |
| """ | |
| ).strip() | |
| def build_inventory(assets: list[UploadedAsset]) -> list[dict[str, Any]]: | |
| inventory = [] | |
| for asset in assets: | |
| inventory.append( | |
| { | |
| "field_name": asset.field_name, | |
| "filename": asset.filename, | |
| "path": str(asset.path), | |
| "content_type": asset.content_type, | |
| "size": asset.size, | |
| "summary": summarize_file(asset.path, asset.filename), | |
| } | |
| ) | |
| return inventory | |
| def summarize_file(path: Path, filename: str) -> dict[str, Any]: | |
| suffix = path.suffix.lower() | |
| try: | |
| if suffix in {".csv", ".tsv"}: | |
| sep = "\t" if suffix == ".tsv" else "," | |
| return summarize_dataframe(pd.read_csv(path, sep=sep, nrows=10000), filename) | |
| if suffix in {".json", ".jsonl", ".ndjson"}: | |
| if suffix in {".jsonl", ".ndjson"}: | |
| return summarize_dataframe(pd.read_json(path, lines=True), filename) | |
| try: | |
| return summarize_dataframe(pd.read_json(path), filename) | |
| except ValueError: | |
| return {"kind": "json", "preview": truncate(path.read_text(encoding="utf-8", errors="replace"), 4000)} | |
| if suffix in {".xlsx", ".xls"}: | |
| return summarize_dataframe(pd.read_excel(path, nrows=5000), filename) | |
| if suffix in {".parquet", ".pq"}: | |
| return summarize_dataframe(pd.read_parquet(path), filename) | |
| if suffix in {".txt", ".md", ".sql"}: | |
| return {"kind": "text", "preview": truncate(path.read_text(encoding="utf-8", errors="replace"), 4000)} | |
| if suffix in {".png", ".jpg", ".jpeg", ".webp"}: | |
| from PIL import Image | |
| with Image.open(path) as image: | |
| return {"kind": "image", "width": image.width, "height": image.height, "mode": image.mode} | |
| except Exception as exc: | |
| return {"kind": "unreadable", "error": str(exc)} | |
| return {"kind": "file", "preview": f"No automatic summary for {filename}."} | |
| def summarize_dataframe(df: pd.DataFrame, name: str) -> dict[str, Any]: | |
| df = df.copy() | |
| df.columns = [stringify_column(col) for col in df.columns] | |
| numeric = df.select_dtypes(include="number") | |
| return { | |
| "kind": "dataframe", | |
| "name": name, | |
| "shape": list(df.shape), | |
| "columns": list(map(str, df.columns)), | |
| "dtypes": {str(col): str(dtype) for col, dtype in df.dtypes.items()}, | |
| "missing": {str(col): int(value) for col, value in df.isna().sum().items()}, | |
| "numeric_summary": numeric.describe().round(6).to_dict() if not numeric.empty else {}, | |
| "head": df.head(5).replace({np.nan: None}).to_dict(orient="records"), | |
| } | |
| def summarize_urls(urls: list[str]) -> list[dict[str, Any]]: | |
| summaries = [] | |
| for url in urls[:4]: | |
| try: | |
| response = requests.get(url, headers={"User-Agent": USER_AGENT}, timeout=20) | |
| response.raise_for_status() | |
| soup = BeautifulSoup(response.text, "html.parser") | |
| tables = pd.read_html(io.StringIO(response.text)) | |
| summaries.append( | |
| { | |
| "url": url, | |
| "title": soup.title.string.strip() if soup.title and soup.title.string else "", | |
| "table_count": len(tables), | |
| "tables": [ | |
| summarize_dataframe(table.head(20), f"{url} table {index}") | |
| for index, table in enumerate(tables[:5]) | |
| ], | |
| } | |
| ) | |
| except Exception as exc: | |
| summaries.append({"url": url, "error": str(exc)}) | |
| return summaries | |
| def find_films_table(tables: list[pd.DataFrame]) -> pd.DataFrame: | |
| for table in tables: | |
| table = table.copy() | |
| table.columns = [stringify_column(col) for col in table.columns] | |
| lower_columns = " ".join(col.lower() for col in table.columns) | |
| if all(token in lower_columns for token in ["rank", "peak"]) and ( | |
| "worldwide" in lower_columns or "gross" in lower_columns | |
| ): | |
| return table | |
| raise ValueError("Could not find highest-grossing films table.") | |
| def find_column(df: pd.DataFrame, pattern: str) -> str: | |
| regex = re.compile(pattern, flags=re.I) | |
| for column in df.columns: | |
| if regex.search(str(column)): | |
| return str(column) | |
| raise KeyError(f"No column matching {pattern!r}. Columns: {list(df.columns)}") | |
| def clean_number_series(series: pd.Series) -> pd.Series: | |
| return pd.to_numeric(series.map(clean_number), errors="coerce") | |
| def clean_year_series(series: pd.Series) -> pd.Series: | |
| return pd.to_numeric(series.astype(str).str.extract(r"(\d{4})", expand=False), errors="coerce") | |
| def clean_number(value: Any) -> float | None: | |
| if value is None or (isinstance(value, float) and math.isnan(value)): | |
| return None | |
| match = re.search(r"-?\d+(?:\.\d+)?", str(value).replace(",", "")) | |
| return float(match.group(0)) if match else None | |
| def parse_money(value: Any) -> float | None: | |
| text = str(value).replace(",", "").lower() | |
| match = re.search(r"\$?\s*([0-9]+(?:\.[0-9]+)?)\s*(billion|million)?", text) | |
| if not match: | |
| return None | |
| amount = float(match.group(1)) | |
| unit = match.group(2) | |
| if unit == "billion": | |
| return amount * 1_000_000_000 | |
| if unit == "million": | |
| return amount * 1_000_000 | |
| return amount | |
| def clean_title(value: Any) -> str: | |
| title = re.sub(r"\[[^\]]+\]", "", str(value)) | |
| title = title.replace("†", "").replace("*", "") | |
| return " ".join(title.split()) | |
| def scatter_with_regression_uri(x: pd.Series, y: pd.Series, xlabel: str, ylabel: str) -> str: | |
| import matplotlib | |
| matplotlib.use("Agg") | |
| import matplotlib.pyplot as plt | |
| x_values = pd.to_numeric(x, errors="coerce") | |
| y_values = pd.to_numeric(y, errors="coerce") | |
| mask = x_values.notna() & y_values.notna() | |
| x_values = x_values[mask].astype(float) | |
| y_values = y_values[mask].astype(float) | |
| fig, ax = plt.subplots(figsize=(4.8, 3.2)) | |
| ax.scatter(x_values, y_values, alpha=0.75) | |
| if len(x_values) >= 2: | |
| slope, intercept = np.polyfit(x_values, y_values, 1) | |
| xs = np.linspace(float(x_values.min()), float(x_values.max()), 100) | |
| ax.plot(xs, slope * xs + intercept, color="red", linestyle=":", linewidth=2) | |
| ax.set_xlabel(xlabel) | |
| ax.set_ylabel(ylabel) | |
| ax.grid(alpha=0.2) | |
| fig.tight_layout() | |
| try: | |
| return figure_to_data_uri(fig, "png", 100_000) | |
| finally: | |
| plt.close(fig) | |
| def figure_to_data_uri(fig: Any, image_format: str = "png", max_bytes: int = 100_000) -> str: | |
| for dpi in (100, 85, 70, 55): | |
| buffer = io.BytesIO() | |
| fig.savefig(buffer, format=image_format, dpi=dpi, bbox_inches="tight") | |
| payload = buffer.getvalue() | |
| if len(payload) <= max_bytes: | |
| encoded = base64.b64encode(payload).decode("ascii") | |
| return f"data:image/{image_format};base64,{encoded}" | |
| buffer = io.BytesIO() | |
| fig.set_size_inches(3.2, 2.2) | |
| fig.savefig(buffer, format=image_format, dpi=45, bbox_inches="tight") | |
| encoded = base64.b64encode(buffer.getvalue()).decode("ascii") | |
| return f"data:image/{image_format};base64,{encoded}" | |
| def fallback_response(questions: str, error: str) -> Any: | |
| keys = extract_requested_object_keys(questions) | |
| if keys: | |
| return {key: None for key in keys} | |
| if asks_for_array(questions): | |
| return [None for _ in range(max(1, infer_question_count(questions)))] | |
| return {"error": truncate(error, 1000)} | |
| def asks_for_array(text: str) -> bool: | |
| return bool(re.search(r"json\s+array|array\s+of", text, flags=re.I)) | |
| def infer_question_count(text: str) -> int: | |
| numbered = re.findall(r"^\s*\d+[\.\)]\s+", text, flags=re.M) | |
| return len(numbered) if numbered else 1 | |
| def extract_requested_object_keys(text: str) -> list[str]: | |
| fenced_objects = re.findall(r"```(?:json)?\s*(\{.*?\})\s*```", text, flags=re.S | re.I) | |
| for block in reversed(fenced_objects): | |
| try: | |
| parsed = json.loads(block) | |
| except json.JSONDecodeError: | |
| continue | |
| if isinstance(parsed, dict) and parsed: | |
| return [str(key) for key in parsed.keys()] | |
| keys = re.findall(r'"([^"\n]{8,}?)"\s*:', text) | |
| question_like = [ | |
| key | |
| for key in keys | |
| if "?" in key | |
| or re.match(r"(?i)^(what|which|how|plot|draw|calculate|find|return|show)\b", key) | |
| ] | |
| return list(dict.fromkeys(question_like or keys)) | |
| def extract_urls(text: str) -> list[str]: | |
| urls = re.findall(r"https?://[^\s<>\]\)\"']+", text) | |
| return [url.rstrip(".,;") for url in urls] | |
| def extract_code(content: str) -> str: | |
| fenced = re.search(r"```(?:python)?\s*(.*?)```", content, flags=re.S | re.I) | |
| return (fenced.group(1) if fenced else content).strip() | |
| def parse_json_from_stdout(stdout: str) -> Any: | |
| decoder = json.JSONDecoder() | |
| text = stdout.strip() | |
| try: | |
| return json.loads(text) | |
| except json.JSONDecodeError: | |
| pass | |
| for index, char in enumerate(text): | |
| if char not in "[{\"-0123456789tnf": | |
| continue | |
| try: | |
| value, _ = decoder.raw_decode(text[index:]) | |
| return value | |
| except json.JSONDecodeError: | |
| continue | |
| raise ValueError("No JSON value found.") | |
| def to_jsonable(value: Any) -> Any: | |
| if isinstance(value, dict): | |
| return {str(key): to_jsonable(item) for key, item in value.items()} | |
| if isinstance(value, (list, tuple)): | |
| return [to_jsonable(item) for item in value] | |
| if isinstance(value, (np.integer,)): | |
| return int(value) | |
| if isinstance(value, (np.floating,)): | |
| value = float(value) | |
| return None if math.isnan(value) or math.isinf(value) else value | |
| if isinstance(value, float): | |
| return None if math.isnan(value) or math.isinf(value) else value | |
| if isinstance(value, (pd.Timestamp,)): | |
| return value.isoformat() | |
| return value | |
| def unique_upload_path(workdir: Path, filename: str) -> Path: | |
| safe = safe_filename(filename) | |
| path = workdir / safe | |
| if not path.exists(): | |
| return path | |
| stem = path.stem | |
| suffix = path.suffix | |
| for index in range(1, 1000): | |
| candidate = workdir / f"{stem}_{index}{suffix}" | |
| if not candidate.exists(): | |
| return candidate | |
| raise HTTPException(status_code=400, detail=f"Too many files named like {filename}.") | |
| def safe_filename(filename: str) -> str: | |
| name = Path(filename).name or "upload" | |
| name = re.sub(r"[^A-Za-z0-9._-]+", "_", name).strip("._") | |
| return name[:120] or "upload" | |
| def is_questions_file(field_name: str, filename: str) -> bool: | |
| field = field_name.lower() | |
| file = Path(filename).name.lower() | |
| return field == "questions.txt" or file == "questions.txt" | |
| def is_upload(value: Any) -> bool: | |
| return hasattr(value, "filename") and hasattr(value, "read") | |
| def decode_text(raw: bytes) -> str: | |
| for encoding in ("utf-8-sig", "utf-8", "cp1252", "latin-1"): | |
| try: | |
| return raw.decode(encoding) | |
| except UnicodeDecodeError: | |
| continue | |
| return raw.decode("utf-8", errors="replace") | |
| def stringify_column(column: Any) -> str: | |
| if isinstance(column, tuple): | |
| parts = [str(part) for part in column if str(part) != "nan"] | |
| column = " ".join(parts) | |
| return re.sub(r"\s+", " ", re.sub(r"\[[^\]]+\]", "", str(column))).strip() | |
| def truncate(text: str, limit: int) -> str: | |
| if len(text) <= limit: | |
| return text | |
| return text[: limit - 200] + "\n... [truncated] ...\n" + text[-200:] | |
| ANALYSIS_HELPERS = r''' | |
| import base64 | |
| import io | |
| import json | |
| import math | |
| import re | |
| import numpy as np | |
| import pandas as pd | |
| def clean_number(value): | |
| if value is None: | |
| return None | |
| if isinstance(value, float) and math.isnan(value): | |
| return None | |
| match = re.search(r"-?\d+(?:\.\d+)?", str(value).replace(",", "")) | |
| return float(match.group(0)) if match else None | |
| def image_to_data_uri(fig, image_format="png", max_bytes=100000): | |
| for dpi in (100, 85, 70, 55): | |
| buffer = io.BytesIO() | |
| fig.savefig(buffer, format=image_format, dpi=dpi, bbox_inches="tight") | |
| payload = buffer.getvalue() | |
| if len(payload) <= max_bytes: | |
| return f"data:image/{image_format};base64,{base64.b64encode(payload).decode('ascii')}" | |
| buffer = io.BytesIO() | |
| fig.set_size_inches(3.2, 2.2) | |
| fig.savefig(buffer, format=image_format, dpi=45, bbox_inches="tight") | |
| return f"data:image/{image_format};base64,{base64.b64encode(buffer.getvalue()).decode('ascii')}" | |
| def _json_default(value): | |
| if isinstance(value, dict): | |
| return {str(key): _json_default(item) for key, item in value.items()} | |
| if isinstance(value, (list, tuple)): | |
| return [_json_default(item) for item in value] | |
| if isinstance(value, np.integer): | |
| return int(value) | |
| if isinstance(value, np.floating): | |
| value = float(value) | |
| return None if math.isnan(value) or math.isinf(value) else value | |
| if isinstance(value, float): | |
| return None if math.isnan(value) or math.isinf(value) else value | |
| if isinstance(value, pd.Timestamp): | |
| return value.isoformat() | |
| return value | |
| def print_json(result): | |
| print(json.dumps(_json_default(result), ensure_ascii=False, separators=(",", ":"))) | |
| ''' | |