#!/usr/bin/env python
"""CSV -> Insights Analyst (#12) — Flask UI.
Upload a CSV (or try the sample) -> deterministic pandas profile -> auto-charts -> a plain-English
briefing grounded ONLY in the computed numbers. Local GPU-fleet LLM by default (free), Claude optional.
pip install -r requirements.txt
python app.py # http://127.0.0.1:7860
"""
import html
import io
import os
import re
import traceback
from flask import Flask, request, render_template_string
import pandas as pd
from profiler import profile_df
from charts import build_charts
from narrator import narrate, BACKEND
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 12 * 1024 * 1024 # 12 MB upload cap
SAMPLE = os.path.join(os.path.dirname(__file__), "samples", "seattle-weather.csv")
PAGE = """
CSV → Insights · GritAI
GritAI · CSV → Insights
Upload data · charts + plain-English findings
#12 · 30-in-15
Turn a spreadsheet into a briefing.
Every statistic and chart is computed
directly from your file. The AI only writes the story — it never invents a number.
{{ body|safe }}
Backend: {{ backend }} · Local GPU fleet by default · GritAI Solutions
"""
def md_to_html(text: str) -> str:
"""Tiny, safe markdown -> HTML (escape first, then a few inline/block rules). No deps."""
out, in_ul = [], False
for raw in text.splitlines():
line = html.escape(raw.rstrip())
line = re.sub(r"\*\*(.+?)\*\*", r"\1", line)
line = re.sub(r"`(.+?)`", r"\1", line)
m_h = re.match(r"^\s*#{1,6}\s*(.+?):?\s*$", raw)
m_li = re.match(r"^\s*[-*]\s+(.+)$", raw)
if m_h:
if in_ul: out.append(""); in_ul = False
out.append(f"
")
if in_ul: out.append("")
return "\n".join(out)
def _stat_rows(prof):
r = [("Rows", f"{prof['shape']['rows']:,}"), ("Columns", prof["shape"]["cols"])]
if prof.get("datetime_cols"):
d = prof["datetime_cols"][0]
r.append(("Date range", f"{d['start']} → {d['end']}"))
r.append(("Numeric cols", len(prof["numeric_cols"])))
r.append(("Categorical cols", len(prof["categorical_cols"])))
if prof.get("duplicate_rows"):
r.append(("Duplicate rows", prof["duplicate_rows"]))
return "".join(f'
{html.escape(str(k))}'
f'{html.escape(str(v))}
' for k, v in r)
def render_results(df, name):
prof = profile_df(df, name=name)
charts = build_charts(df, prof)
findings = narrate(prof)
chart_html = "".join(
f'
'
for c in charts)
flags_html = ""
if prof.get("flags"):
items = "".join(f"
⚠ {html.escape(f)}
" for f in prof["flags"])
flags_html = f'
Data-quality flags
{items}
'
return f"""
01Overview — {html.escape(name)}
Shape
{_stat_rows(prof)}
{flags_html}
02Findings
{md_to_html(findings)}
03Charts
{chart_html}
"""
@app.route("/")
def index():
return render_template_string(PAGE, body="", backend=BACKEND)
@app.route("/analyze", methods=["POST"])
def analyze():
try:
if request.form.get("sample"):
df = pd.read_csv(SAMPLE)
name = "seattle-weather.csv (sample)"
else:
f = request.files.get("file")
if not f or not f.filename:
return render_template_string(PAGE, backend=BACKEND,
body='
Please choose a .csv file first.
')
if not f.filename.lower().endswith(".csv"):
return render_template_string(PAGE, backend=BACKEND,
body='
That doesn\'t look like a .csv file.
')
df = pd.read_csv(io.BytesIO(f.read()))
name = os.path.basename(f.filename)
if df.empty or df.shape[1] == 0:
return render_template_string(PAGE, backend=BACKEND,
body='
That file has no rows/columns to analyze.
')
return render_template_string(PAGE, body=render_results(df, name), backend=BACKEND)
except Exception as e:
print(f"analyze error: {type(e).__name__}") # TYPE only — never repr/secrets or raw data
return render_template_string(PAGE, backend=BACKEND,
body=f'
Could not read that file as a CSV '
f'({type(e).__name__}). Try a standard comma-separated file.
')
if __name__ == "__main__":
port = int(os.environ.get("PORT", "7860"))
app.run(host="0.0.0.0", port=port, debug=False)