csv-insights / app.py
BonusLockSMith's picture
Upload folder using huggingface_hub
e2f9a1d verified
Raw History Blame Contribute Delete
8.46 kB
#!/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 = """<!doctype html><html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>CSV → Insights · GritAI</title>
<link rel="stylesheet" href="/static/gritai.css">
<style>
.wrap{max-width:1040px;margin:0 auto;padding:34px 22px 80px}
.up{display:flex;gap:14px;flex-wrap:wrap;align-items:center;margin-top:18px}
input[type=file]{color:var(--dim);font-family:var(--mono);font-size:13px}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px;margin-top:18px}
.chart img{width:100%;height:auto;border:1px solid var(--line);border-radius:3px;display:block}
.stat{display:flex;justify-content:space-between;gap:12px;padding:7px 0;border-bottom:1px solid var(--line);font-size:13.5px}
.stat .k{color:var(--dim)} .stat .v{font-family:var(--mono);color:var(--ink)}
.findings{font-size:15px;line-height:1.7}
.findings h3{font-family:var(--disp);color:var(--grit);font-size:15px;letter-spacing:.06em;text-transform:uppercase;margin:18px 0 8px}
.findings li{margin:5px 0}
.flag{color:var(--bad)} .chip{color:var(--grit)}
.err{border:1px solid var(--bad);color:var(--bad);padding:14px 16px;border-radius:3px;background:rgba(255,107,107,.06)}
.muted{color:var(--dim2);font-family:var(--mono);font-size:11px;letter-spacing:.16em;text-transform:uppercase}
</style></head><body>
<div class="wrap">
<header style="display:flex;align-items:center;gap:14px">
<svg width="38" height="38" viewBox="0 0 38 38" fill="none"><rect x="1" y="1" width="36" height="36" rx="2" stroke="#2b303c"/><path d="M27 12.5A9 9 0 1 0 28 22H19" stroke="#ff5a2a" stroke-width="2.4" stroke-linecap="square"/><rect x="10.5" y="10.5" width="3" height="3" fill="#ff5a2a"/></svg>
<div class="brand"><div class="co">Grit<b>AI</b> · CSV → Insights</div>
<div class="sub">Upload data · charts + plain-English findings</div></div>
<div style="flex:1"></div><div class="tag hot">#12 · 30-in-15</div>
</header>
<div class="hero" style="margin-top:26px">
<h1>Turn a spreadsheet into a briefing.</h1>
<p style="color:var(--dim);max-width:60ch;margin-top:12px">Every statistic and chart is computed
directly from your file. The AI only writes the story — it never invents a number.</p>
</div>
<form class="panel" style="padding:20px" method="post" action="/analyze" enctype="multipart/form-data">
<div class="muted">Analyze a CSV</div>
<div class="up">
<input type="file" name="file" accept=".csv">
<button class="btn" type="submit">Analyze ▸</button>
<button class="btn ghost" type="submit" name="sample" value="1">Try the sample (Seattle weather)</button>
</div>
</form>
{{ body|safe }}
<p class="muted" style="margin-top:40px">Backend: {{ backend }} · Local GPU fleet by default · GritAI Solutions</p>
</div></body></html>"""
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"<strong>\1</strong>", line)
line = re.sub(r"`(.+?)`", r"<code>\1</code>", 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("</ul>"); in_ul = False
out.append(f"<h3>{html.escape(m_h.group(1))}</h3>")
elif m_li:
if not in_ul: out.append("<ul>"); in_ul = True
item = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", html.escape(m_li.group(1)))
out.append(f"<li>{item}</li>")
elif line.strip():
if in_ul: out.append("</ul>"); in_ul = False
out.append(f"<p>{line}</p>")
if in_ul: out.append("</ul>")
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'<div class="stat"><span class="k">{html.escape(str(k))}</span>'
f'<span class="v">{html.escape(str(v))}</span></div>' 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'<div class="chart card" style="padding:10px"><img src="{c["img"]}" alt="{html.escape(c["title"])}"></div>'
for c in charts)
flags_html = ""
if prof.get("flags"):
items = "".join(f"<li class='flag'>⚠ {html.escape(f)}</li>" for f in prof["flags"])
flags_html = f'<div class="card"><div class="muted">Data-quality flags</div><ul>{items}</ul></div>'
return f"""
<div class="sec" style="display:flex;align-items:center;gap:12px;margin:34px 0 6px">
<span class="num">01</span><span class="name">Overview — {html.escape(name)}</span>
<span class="rule"></span></div>
<div class="grid">
<div class="card"><div class="muted">Shape</div>{_stat_rows(prof)}</div>
{flags_html}
</div>
<div class="sec" style="display:flex;align-items:center;gap:12px;margin:34px 0 6px">
<span class="num">02</span><span class="name">Findings</span><span class="rule"></span></div>
<div class="panel findings" style="padding:22px 24px">{md_to_html(findings)}</div>
<div class="sec" style="display:flex;align-items:center;gap:12px;margin:34px 0 6px">
<span class="num">03</span><span class="name">Charts</span><span class="rule"></span></div>
<div class="grid">{chart_html}</div>
"""
@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='<div class="err" style="margin-top:20px">Please choose a .csv file first.</div>')
if not f.filename.lower().endswith(".csv"):
return render_template_string(PAGE, backend=BACKEND,
body='<div class="err" style="margin-top:20px">That doesn\'t look like a .csv file.</div>')
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='<div class="err" style="margin-top:20px">That file has no rows/columns to analyze.</div>')
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'<div class="err" style="margin-top:20px">Could not read that file as a CSV '
f'({type(e).__name__}). Try a standard comma-separated file.</div>')
if __name__ == "__main__":
port = int(os.environ.get("PORT", "7860"))
app.run(host="0.0.0.0", port=port, debug=False)