dev_sandbox/.pi/agent/extensions/insights/render.py
2026-06-18 14:52:14 -04:00

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 &middot; 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)