240 lines
9.3 KiB
Python
240 lines
9.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Render the insights HTML report from stats JSON + LLM narrative JSON.
|
|
|
|
Usage: render.py <stats.json> <narrative.json> <out.html>
|
|
narrative.json is optional (pass "-" to skip). It may contain:
|
|
{ "summary": str, "highlights": [str], "suggestions": [{"title","detail"}],
|
|
"friction": [str] }
|
|
"""
|
|
import sys, os, json, html
|
|
|
|
|
|
def esc(s):
|
|
return html.escape(str(s if s is not None else ""))
|
|
|
|
|
|
DOW = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
|
|
|
|
|
def bar_chart(values, labels, color="#7c5cff", height=120):
|
|
mx = max(values) if values and max(values) > 0 else 1
|
|
cells = []
|
|
for v, lab in zip(values, labels):
|
|
h = int((v / mx) * height) if mx else 0
|
|
cells.append(
|
|
f'<div class="bar-col"><div class="bar" style="height:{h}px;background:{color}" '
|
|
f'title="{esc(lab)}: {v}"></div><div class="bar-lab">{esc(lab)}</div></div>'
|
|
)
|
|
return f'<div class="bars" style="height:{height+24}px">' + "".join(cells) + "</div>"
|
|
|
|
|
|
def stat_card(label, value, sub=""):
|
|
sub_html = f'<div class="stat-sub">{esc(sub)}</div>' if sub else ""
|
|
return (
|
|
f'<div class="card stat"><div class="stat-val">{esc(value)}</div>'
|
|
f'<div class="stat-lab">{esc(label)}</div>{sub_html}</div>'
|
|
)
|
|
|
|
|
|
def fmt_num(n):
|
|
try:
|
|
n = float(n)
|
|
except Exception:
|
|
return str(n)
|
|
if n >= 1_000_000_000:
|
|
return f"{n/1_000_000_000:.1f}B"
|
|
if n >= 1_000_000:
|
|
return f"{n/1_000_000:.1f}M"
|
|
if n >= 1_000:
|
|
return f"{n/1_000:.1f}K"
|
|
if n == int(n):
|
|
return str(int(n))
|
|
return f"{n:.2f}"
|
|
|
|
|
|
def build(stats, narr):
|
|
t = stats["totals"]
|
|
parts = []
|
|
A = parts.append
|
|
|
|
A('<section class="hero">')
|
|
A(f'<h1>pi insights</h1>')
|
|
A(f'<p class="sub">Last {stats["window_days"]} days · generated {esc(stats["generated_at"][:16].replace("T"," "))} UTC</p>')
|
|
if narr.get("summary"):
|
|
A(f'<p class="summary">{esc(narr["summary"])}</p>')
|
|
A("</section>")
|
|
|
|
# Top stats
|
|
A('<section class="grid">')
|
|
A(stat_card("Sessions", fmt_num(t["sessions"]), f'{stats["active_days"]} active days'))
|
|
A(stat_card("Prompts sent", fmt_num(t["user_msgs"])))
|
|
A(stat_card("Assistant turns", fmt_num(t["assistant_msgs"])))
|
|
A(stat_card("Tool calls", fmt_num(t["tool_calls"])))
|
|
A(stat_card("Total tokens", fmt_num(t["total_tokens"])))
|
|
A(stat_card("Total cost", f'${t["cost"]:.2f}'))
|
|
if stats.get("avg_session_duration_min"):
|
|
A(stat_card("Avg session", f'{stats["avg_session_duration_min"]:.0f} min'))
|
|
cache = t["cache_read"] + t["cache_write"]
|
|
if cache:
|
|
rate = (t["cache_read"] / cache * 100) if cache else 0
|
|
A(stat_card("Cache hit", f'{rate:.0f}%', f'{fmt_num(t["cache_read"])} read'))
|
|
A("</section>")
|
|
return parts
|
|
|
|
|
|
def build_charts(stats, narr, parts):
|
|
A = parts.append
|
|
|
|
# Activity by hour
|
|
hours = stats["hour_histogram"]
|
|
hour_labels = [str(h) if h % 3 == 0 else "" for h in range(24)]
|
|
A('<section class="card wide"><h2>Activity by hour of day</h2>')
|
|
A(bar_chart(hours, hour_labels, "#7c5cff"))
|
|
A("</section>")
|
|
|
|
# Activity by weekday
|
|
dow = stats["dow_histogram"]
|
|
A('<section class="card wide"><h2>Activity by weekday</h2>')
|
|
A(bar_chart(dow, DOW, "#21c7a8"))
|
|
A("</section>")
|
|
|
|
# Daily
|
|
daily = stats.get("daily_histogram", {})
|
|
if daily:
|
|
keys = list(daily.keys())
|
|
vals = [daily[k] for k in keys]
|
|
labs = [k[5:] if i % max(1, len(keys)//8) == 0 else "" for i, k in enumerate(keys)]
|
|
A('<section class="card wide"><h2>Sessions per day</h2>')
|
|
A(bar_chart(vals, labs, "#f5a623"))
|
|
A("</section>")
|
|
|
|
# Projects table
|
|
A('<section class="card wide"><h2>Projects</h2><table>')
|
|
A('<tr><th>Project</th><th>Sessions</th><th>Prompts</th><th>Tool calls</th><th>Tokens</th><th>Cost</th></tr>')
|
|
for p in stats["projects"][:15]:
|
|
A(f'<tr><td>{esc(p["project"])}</td><td>{p["sessions"]}</td><td>{p["user_msgs"]}</td>'
|
|
f'<td>{p["tool_calls"]}</td><td>{fmt_num(p["tokens"])}</td><td>${p["cost"]:.2f}</td></tr>')
|
|
A("</table></section>")
|
|
|
|
# Tool usage
|
|
tu = stats["tool_usage"]
|
|
if tu:
|
|
names = [x["name"] for x in tu]
|
|
counts = [x["count"] for x in tu]
|
|
A('<section class="card wide"><h2>Tool usage</h2>')
|
|
A(bar_chart(counts, names, "#ff6b9d"))
|
|
A("</section>")
|
|
|
|
# Models
|
|
mu = stats["model_usage"]
|
|
if mu:
|
|
A('<section class="card wide"><h2>Models used</h2><table><tr><th>Model</th><th>Sessions</th></tr>')
|
|
for m in mu:
|
|
short = m["model"].split("/")[-1]
|
|
A(f'<tr><td title="{esc(m["model"])}">{esc(short)}</td><td>{m["sessions"]}</td></tr>')
|
|
A("</table></section>")
|
|
|
|
|
|
def build_narrative(stats, narr, parts):
|
|
A = parts.append
|
|
|
|
if narr.get("highlights"):
|
|
A('<section class="card wide narr"><h2>Highlights</h2><ul>')
|
|
for h in narr["highlights"]:
|
|
A(f"<li>{esc(h)}</li>")
|
|
A("</ul></section>")
|
|
|
|
if narr.get("friction"):
|
|
A('<section class="card wide narr"><h2>Friction patterns</h2><ul>')
|
|
for h in narr["friction"]:
|
|
A(f"<li>{esc(h)}</li>")
|
|
A("</ul></section>")
|
|
|
|
if narr.get("suggestions"):
|
|
A('<section class="card wide narr"><h2>Suggestions</h2><div class="suggestions">')
|
|
for s in narr["suggestions"]:
|
|
if isinstance(s, dict):
|
|
A(f'<div class="sugg"><div class="sugg-title">{esc(s.get("title",""))}</div>'
|
|
f'<div class="sugg-detail">{esc(s.get("detail",""))}</div></div>')
|
|
else:
|
|
A(f'<div class="sugg"><div class="sugg-detail">{esc(s)}</div></div>')
|
|
A("</div></section>")
|
|
|
|
# Longest sessions
|
|
ls = stats.get("longest_sessions", [])
|
|
if ls:
|
|
A('<section class="card wide"><h2>Longest sessions</h2><table>')
|
|
A('<tr><th>Project</th><th>Started</th><th>Duration</th><th>Prompts</th><th>Cost</th></tr>')
|
|
for s in ls:
|
|
A(f'<tr><td>{esc(s["project"])}</td><td>{esc((s["start"] or "")[:16].replace("T"," "))}</td>'
|
|
f'<td>{s["duration_min"]:.0f} min</td><td>{s["user_msgs"]}</td><td>${s["cost"]:.2f}</td></tr>')
|
|
A("</table></section>")
|
|
|
|
|
|
CSS = """
|
|
:root { --bg:#0e0e14; --card:#181822; --fg:#e8e8f0; --muted:#9090a8; --accent:#7c5cff; }
|
|
* { box-sizing:border-box; }
|
|
body { margin:0; background:var(--bg); color:var(--fg);
|
|
font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; }
|
|
.wrap { max-width:1080px; margin:0 auto; padding:32px 20px 80px; }
|
|
.hero { text-align:center; padding:24px 0 8px; }
|
|
.hero h1 { font-size:42px; margin:0; background:linear-gradient(90deg,#7c5cff,#21c7a8);
|
|
-webkit-background-clip:text; background-clip:text; color:transparent; }
|
|
.hero .sub { color:var(--muted); margin:4px 0 16px; }
|
|
.hero .summary { max-width:720px; margin:0 auto; font-size:17px; color:#d0d0e0; }
|
|
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr));
|
|
gap:14px; margin:28px 0; }
|
|
.card { background:var(--card); border:1px solid #25252f; border-radius:14px; padding:18px 20px; }
|
|
.card.wide { margin:18px 0; }
|
|
.card h2 { font-size:15px; text-transform:uppercase; letter-spacing:.06em;
|
|
color:var(--muted); margin:0 0 16px; font-weight:600; }
|
|
.stat { text-align:center; }
|
|
.stat-val { font-size:30px; font-weight:700; }
|
|
.stat-lab { color:var(--muted); font-size:13px; margin-top:4px; }
|
|
.stat-sub { color:#6a6a82; font-size:11px; margin-top:2px; }
|
|
.bars { display:flex; align-items:flex-end; gap:4px; }
|
|
.bar-col { flex:1; display:flex; flex-direction:column; align-items:center;
|
|
justify-content:flex-end; height:100%; }
|
|
.bar { width:100%; border-radius:4px 4px 0 0; min-height:2px; transition:opacity .15s; }
|
|
.bar:hover { opacity:.7; }
|
|
.bar-lab { font-size:10px; color:var(--muted); margin-top:6px; white-space:nowrap; }
|
|
table { width:100%; border-collapse:collapse; font-size:14px; }
|
|
th { text-align:left; color:var(--muted); font-weight:600; padding:8px 10px;
|
|
border-bottom:1px solid #2a2a38; font-size:12px; text-transform:uppercase; }
|
|
td { padding:8px 10px; border-bottom:1px solid #1f1f29; }
|
|
tr:hover td { background:#1d1d28; }
|
|
.narr ul { margin:0; padding-left:20px; } .narr li { margin:8px 0; }
|
|
.suggestions { display:grid; gap:12px; }
|
|
.sugg { background:#1f1f2b; border-left:3px solid var(--accent); border-radius:8px; padding:12px 16px; }
|
|
.sugg-title { font-weight:600; margin-bottom:4px; }
|
|
.sugg-detail { color:#c0c0d4; font-size:14px; }
|
|
"""
|
|
|
|
|
|
def render(stats, narr):
|
|
parts = build(stats, narr)
|
|
build_narrative(stats, narr, parts)
|
|
build_charts(stats, narr, parts)
|
|
body = "\n".join(parts)
|
|
return f"""<!doctype html>
|
|
<html lang="en"><head><meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>pi insights</title><style>{CSS}</style></head>
|
|
<body><div class="wrap">{body}</div></body></html>"""
|
|
|
|
|
|
if __name__ == "__main__":
|
|
stats_path, narr_path, out_path = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
with open(stats_path) as f:
|
|
stats = json.load(f)
|
|
narr = {}
|
|
if narr_path and narr_path != "-" and os.path.exists(narr_path):
|
|
try:
|
|
with open(narr_path) as f:
|
|
narr = json.load(f)
|
|
except Exception:
|
|
narr = {}
|
|
html_out = render(stats, narr)
|
|
with open(out_path, "w") as f:
|
|
f.write(html_out)
|
|
print(out_path)
|