Claude Code Claude Opus 4.6 commited on
Commit
cc1784f
·
1 Parent(s): d43d04a

feat: Add chat analytics dashboard to Cain

Browse files

- Create app_analytics.py with Gradio.Blocks interface
- Add load_analytics_data() function to read from session-archive.jsonl
- Implement 4 chart types: line (messages/hour), bar (agent responses), pie (sentiment), DataFrame (top sessions)
- Add Analytics tab to main app.py with embedded dashboard
- Use Plotly for interactive charts with minimal functional design

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Files changed (2) hide show
  1. app.py +61 -0
  2. app_analytics.py +281 -0
app.py CHANGED
@@ -1845,6 +1845,67 @@ def create_agent_office():
1845
  label="Session Statistics"
1846
  )
1847
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1848
  # ========== Footer ==========
1849
  gr.Markdown("---")
1850
  with gr.Row():
 
1845
  label="Session Statistics"
1846
  )
1847
 
1848
+ # ========== Tab: Analytics Dashboard ==========
1849
+ with gr.Tab("📊 Analytics"):
1850
+ gr.Markdown("## 📊 Chat Analytics Dashboard")
1851
+ gr.Markdown("Visualize conversation patterns and agent performance metrics.")
1852
+
1853
+ # Import analytics functions
1854
+ from app_analytics import load_analytics_data, create_line_chart, create_bar_chart, create_pie_chart
1855
+ import pandas as pd
1856
+
1857
+ # Load initial data
1858
+ initial_analytics = load_analytics_data()
1859
+
1860
+ with gr.Row():
1861
+ analytics_refresh_btn = gr.Button("🔄 Refresh Analytics", variant="primary")
1862
+
1863
+ with gr.Row():
1864
+ with gr.Column(scale=1):
1865
+ analytics_line_plot = gr.Plot(
1866
+ value=create_line_chart(initial_analytics["messages_per_hour"]),
1867
+ label="Messages per Hour (Last 24h)"
1868
+ )
1869
+ with gr.Column(scale=1):
1870
+ analytics_bar_plot = gr.Plot(
1871
+ value=create_bar_chart(initial_analytics["agent_distribution"]),
1872
+ label="Agent Response Distribution"
1873
+ )
1874
+
1875
+ with gr.Row():
1876
+ with gr.Column(scale=1):
1877
+ analytics_pie_plot = gr.Plot(
1878
+ value=create_pie_chart(initial_analytics["sentiment_counts"]),
1879
+ label="Sentiment Analysis"
1880
+ )
1881
+ with gr.Column(scale=1):
1882
+ analytics_df_data = initial_analytics["top_sessions"] if initial_analytics["top_sessions"] else [{
1883
+ "session": "No data", "activity_count": 0, "last_active": "N/A"
1884
+ }]
1885
+ analytics_sessions_df = gr.Dataframe(
1886
+ value=pd.DataFrame(analytics_df_data),
1887
+ label="Top 10 Active Sessions",
1888
+ headers=["Session", "Activity Count", "Last Active"],
1889
+ interactive=False
1890
+ )
1891
+
1892
+ # Analytics refresh handler
1893
+ def refresh_analytics_dashboard():
1894
+ data = load_analytics_data()
1895
+ line_fig = create_line_chart(data["messages_per_hour"])
1896
+ bar_fig = create_bar_chart(data["agent_distribution"])
1897
+ pie_fig = create_pie_chart(data["sentiment_counts"])
1898
+ df_data = data["top_sessions"] if data["top_sessions"] else [{
1899
+ "session": "No data", "activity_count": 0, "last_active": "N/A"
1900
+ }]
1901
+ df = pd.DataFrame(df_data)
1902
+ return line_fig, bar_fig, pie_fig, df
1903
+
1904
+ analytics_refresh_btn.click(
1905
+ fn=refresh_analytics_dashboard,
1906
+ outputs=[analytics_line_plot, analytics_bar_plot, analytics_pie_plot, analytics_sessions_df]
1907
+ )
1908
+
1909
  # ========== Footer ==========
1910
  gr.Markdown("---")
1911
  with gr.Row():
app_analytics.py ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Chat Analytics Dashboard for HuggingClaw Cain
4
+
5
+ Visualizes conversation patterns and agent performance metrics.
6
+ """
7
+ import os
8
+ import json
9
+ from pathlib import Path
10
+ from datetime import datetime, timedelta
11
+ from typing import Dict, List, Any, Optional
12
+ from collections import defaultdict, Counter
13
+
14
+ import gradio as gr
15
+ import pandas as pd
16
+ import plotly.graph_objects as go
17
+ import plotly.express as px
18
+
19
+ # ========== Path Constants ==========
20
+ BASE_DIR = Path(__file__).resolve().parent
21
+ SESSION_ARCHIVE_PATH = BASE_DIR / ".openclaw" / "agents" / "logs" / "session-archive.jsonl"
22
+
23
+
24
+ def load_analytics_data() -> Dict[str, Any]:
25
+ """
26
+ Load and parse analytics data from session-archive.jsonl.
27
+
28
+ Returns:
29
+ Dictionary containing aggregated statistics:
30
+ - messages_per_hour: List of (hour, count) tuples for last 24h
31
+ - agent_distribution: Dict of agent names to response counts
32
+ - sentiment_counts: Dict of sentiment (positive/negative/neutral) to counts
33
+ - top_sessions: List of top 10 active session dicts
34
+ """
35
+ default_result = {
36
+ "messages_per_hour": [],
37
+ "agent_distribution": {},
38
+ "sentiment_counts": {"positive": 0, "negative": 0, "neutral": 0},
39
+ "top_sessions": []
40
+ }
41
+
42
+ if not SESSION_ARCHIVE_PATH.exists():
43
+ return default_result
44
+
45
+ # Parse all records
46
+ records = []
47
+ try:
48
+ with open(SESSION_ARCHIVE_PATH, 'r') as f:
49
+ for line in f:
50
+ if line.strip():
51
+ try:
52
+ records.append(json.loads(line))
53
+ except json.JSONDecodeError:
54
+ continue
55
+ except Exception:
56
+ return default_result
57
+
58
+ if not records:
59
+ return default_result
60
+
61
+ # Parse timestamps and filter for last 24 hours
62
+ now = datetime.utcnow()
63
+ cutoff = now - timedelta(hours=24)
64
+
65
+ parsed_records = []
66
+ for r in records:
67
+ ts_str = r.get("timestamp")
68
+ if not ts_str:
69
+ continue
70
+ try:
71
+ # Handle ISO format with timezone
72
+ if ts_str.endswith("+00:00"):
73
+ ts_str = ts_str.replace("+00:00", "").replace("Z", "")
74
+ ts = datetime.fromisoformat(ts_str.replace("Z", ""))
75
+ parsed_records.append({
76
+ "timestamp": ts,
77
+ "agent": r.get("agent", "unknown"),
78
+ "state": r.get("state", "unknown"),
79
+ "action": r.get("action", "state_change"),
80
+ "type": r.get("type", "state_change")
81
+ })
82
+ except (ValueError, AttributeError):
83
+ continue
84
+
85
+ # Filter for last 24 hours for hourly chart
86
+ recent_records = [r for r in parsed_records if r["timestamp"] >= cutoff]
87
+
88
+ # 1. Messages per hour (last 24h)
89
+ hour_counts = defaultdict(int)
90
+ for r in recent_records:
91
+ hour_key = r["timestamp"].strftime("%Y-%m-%d %H:00")
92
+ hour_counts[hour_key] += 1
93
+
94
+ # Fill missing hours with 0
95
+ messages_per_hour = []
96
+ for i in range(24):
97
+ hour_time = now - timedelta(hours=23-i)
98
+ hour_key = hour_time.strftime("%Y-%m-%d %H:00")
99
+ messages_per_hour.append({
100
+ "hour": hour_time.strftime("%H:00"),
101
+ "count": hour_counts.get(hour_key, 0)
102
+ })
103
+
104
+ # 2. Agent response distribution (state changes by agent)
105
+ agent_counts = Counter(r["agent"] for r in parsed_records if r["agent"] != "unknown")
106
+ agent_distribution = dict(agent_counts.most_common())
107
+
108
+ # 3. Sentiment analysis (based on state: success/error/other)
109
+ sentiment_counts = {"positive": 0, "negative": 0, "neutral": 0}
110
+ for r in parsed_records:
111
+ state = r.get("state", "").lower()
112
+ if state == "success":
113
+ sentiment_counts["positive"] += 1
114
+ elif state in ("error", "failed"):
115
+ sentiment_counts["negative"] += 1
116
+ else:
117
+ sentiment_counts["neutral"] += 1
118
+
119
+ # 4. Top 10 active sessions (by agent activity)
120
+ session_activity = defaultdict(lambda: {"count": 0, "last_active": None})
121
+ for r in parsed_records:
122
+ agent = r["agent"]
123
+ session_activity[agent]["count"] += 1
124
+ if session_activity[agent]["last_active"] is None or r["timestamp"] > session_activity[agent]["last_active"]:
125
+ session_activity[agent]["last_active"] = r["timestamp"]
126
+
127
+ top_sessions = [
128
+ {
129
+ "session": agent,
130
+ "activity_count": data["count"],
131
+ "last_active": data["last_active"].strftime("%Y-%m-%d %H:%M:%S") if data["last_active"] else "N/A"
132
+ }
133
+ for agent, data in sorted(session_activity.items(), key=lambda x: x[1]["count"], reverse=True)[:10]
134
+ ]
135
+
136
+ return {
137
+ "messages_per_hour": messages_per_hour,
138
+ "agent_distribution": agent_distribution,
139
+ "sentiment_counts": sentiment_counts,
140
+ "top_sessions": top_sessions
141
+ }
142
+
143
+
144
+ def create_line_chart(data: List[Dict]) -> go.Figure:
145
+ """Create line chart for messages per hour."""
146
+ hours = [d["hour"] for d in data]
147
+ counts = [d["count"] for d in data]
148
+
149
+ fig = go.Figure()
150
+ fig.add_trace(go.Scatter(
151
+ x=hours,
152
+ y=counts,
153
+ mode="lines+markers",
154
+ name="Messages",
155
+ line=dict(color="#3b82f6", width=2),
156
+ marker=dict(size=6)
157
+ ))
158
+
159
+ fig.update_layout(
160
+ title="Messages per Hour (Last 24h)",
161
+ xaxis_title="Hour",
162
+ yaxis_title="Message Count",
163
+ hovermode="x unified",
164
+ height=300,
165
+ margin=dict(l=10, r=10, t=40, b=40)
166
+ )
167
+ return fig
168
+
169
+
170
+ def create_bar_chart(data: Dict[str, int]) -> go.Figure:
171
+ """Create bar chart for agent response distribution."""
172
+ agents = list(data.keys())
173
+ counts = list(data.values())
174
+
175
+ fig = go.Figure()
176
+ fig.add_trace(go.Bar(
177
+ x=agents,
178
+ y=counts,
179
+ marker_color="#10b981"
180
+ ))
181
+
182
+ fig.update_layout(
183
+ title="Agent Response Distribution",
184
+ xaxis_title="Agent",
185
+ yaxis_title="Response Count",
186
+ height=300,
187
+ margin=dict(l=10, r=10, t=40, b=40)
188
+ )
189
+ return fig
190
+
191
+
192
+ def create_pie_chart(data: Dict[str, int]) -> go.Figure:
193
+ """Create pie chart for sentiment analysis."""
194
+ labels = list(data.keys())
195
+ values = list(data.values())
196
+ colors = ["#10b981", "#ef4444", "#6b7280"]
197
+
198
+ fig = go.Figure()
199
+ fig.add_trace(go.Pie(
200
+ labels=labels,
201
+ values=values,
202
+ marker=dict(colors=colors),
203
+ textinfo="label+percent"
204
+ ))
205
+
206
+ fig.update_layout(
207
+ title="Sentiment Analysis",
208
+ height=300,
209
+ margin=dict(l=10, r=10, t=40, b=40)
210
+ )
211
+ return fig
212
+
213
+
214
+ def refresh_analytics():
215
+ """Refresh all analytics data and charts."""
216
+ data = load_analytics_data()
217
+
218
+ # Update charts
219
+ line_fig = create_line_chart(data["messages_per_hour"])
220
+ bar_fig = create_bar_chart(data["agent_distribution"])
221
+ pie_fig = create_pie_chart(data["sentiment_counts"])
222
+
223
+ # Update dataframe
224
+ df_data = data["top_sessions"] if data["top_sessions"] else [{
225
+ "session": "No data", "activity_count": 0, "last_active": "N/A"
226
+ }]
227
+ df = pd.DataFrame(df_data)
228
+
229
+ return line_fig, bar_fig, pie_fig, df
230
+
231
+
232
+ def create_analytics_interface():
233
+ """Create the Gradio analytics dashboard interface."""
234
+ # Load initial data
235
+ initial_data = load_analytics_data()
236
+
237
+ # Create initial charts
238
+ initial_line = create_line_chart(initial_data["messages_per_hour"])
239
+ initial_bar = create_bar_chart(initial_data["agent_distribution"])
240
+ initial_pie = create_pie_chart(initial_data["sentiment_counts"])
241
+
242
+ # Create initial dataframe
243
+ df_data = initial_data["top_sessions"] if initial_data["top_sessions"] else [{
244
+ "session": "No data", "activity_count": 0, "last_active": "N/A"
245
+ }]
246
+ initial_df = pd.DataFrame(df_data)
247
+
248
+ with gr.Blocks(title="Cain Analytics Dashboard", theme=gr.themes.Soft()) as app:
249
+ gr.Markdown("# 📊 Cain Chat Analytics Dashboard")
250
+
251
+ with gr.Row():
252
+ refresh_btn = gr.Button("🔄 Refresh", variant="primary")
253
+
254
+ with gr.Row():
255
+ with gr.Column(scale=1):
256
+ line_plot = gr.Plot(value=initial_line)
257
+ with gr.Column(scale=1):
258
+ bar_plot = gr.Plot(value=initial_bar)
259
+
260
+ with gr.Row():
261
+ with gr.Column(scale=1):
262
+ pie_plot = gr.Plot(value=initial_pie)
263
+ with gr.Column(scale=1):
264
+ sessions_df = gr.Dataframe(
265
+ value=initial_df,
266
+ label="Top 10 Active Sessions",
267
+ headers=["Session", "Activity Count", "Last Active"]
268
+ )
269
+
270
+ # Bind refresh button
271
+ refresh_btn.click(
272
+ refresh_analytics,
273
+ outputs=[line_plot, bar_plot, pie_plot, sessions_df]
274
+ )
275
+
276
+ return app
277
+
278
+
279
+ if __name__ == "__main__":
280
+ app = create_analytics_interface()
281
+ app.launch(server_name="0.0.0.0", server_port=7861, share=False)