Claude Code Claude Opus 4.6 commited on
Commit
bb0a3f8
Β·
1 Parent(s): aeb2cae

Add Conversation Analytics with HF Dataset persistence

Browse files

- Add conversation_analytics.py module with metrics tracking
- Track message count, tool usage, response times, errors
- Add analytics dashboard to Gradio UI with summary view
- Add API endpoints for analytics stats and history
- Support HF Dataset sync for conversation persistence
- Integrate analytics tracking into chat_with_brain function

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

Files changed (2) hide show
  1. .openclaw/agents/conversation_analytics.py +500 -0
  2. app.py +191 -18
.openclaw/agents/conversation_analytics.py ADDED
@@ -0,0 +1,500 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Conversation Analytics Module for HuggingClaw Cain
4
+
5
+ Tracks conversation metrics and provides analytics including:
6
+ - Message count and statistics
7
+ - Tool usage tracking
8
+ - Response time metrics
9
+ - HF Dataset persistence
10
+ - Conversation summaries
11
+ """
12
+
13
+ import json
14
+ import os
15
+ import time
16
+ from typing import Dict, List, Any, Optional
17
+ from pathlib import Path
18
+ from datetime import datetime, timedelta
19
+ from dataclasses import dataclass, asdict
20
+ from collections import defaultdict
21
+ import threading
22
+
23
+
24
+ # Analytics storage paths
25
+ OPENCLAW_AGENTS_DIR = Path(__file__).parent
26
+ ANALYTICS_DIR = OPENCLAW_AGENTS_DIR / "analytics"
27
+ ANALYTICS_FILE = ANALYTICS_DIR / "conversation_analytics.json"
28
+ CONVERSATION_HISTORY_FILE = ANALYTICS_DIR / "conversation_history.jsonl"
29
+
30
+
31
+ @dataclass
32
+ class ConversationMetrics:
33
+ """Metrics for a single conversation"""
34
+ conversation_id: str
35
+ timestamp: str
36
+ message_count: int
37
+ tool_calls: Dict[str, int]
38
+ avg_response_time: float
39
+ total_duration: float
40
+ status: str
41
+ error_count: int
42
+
43
+
44
+ @dataclass
45
+ class MessageRecord:
46
+ """Record of a single message"""
47
+ conversation_id: str
48
+ timestamp: str
49
+ role: str # "user" or "assistant"
50
+ content: str
51
+ tools_used: List[str]
52
+ response_time: float
53
+ success: bool
54
+
55
+
56
+ class ConversationAnalytics:
57
+ """
58
+ Tracks conversation analytics with HF Dataset persistence support.
59
+ Thread-safe for concurrent access.
60
+ """
61
+
62
+ def __init__(self, hf_dataset_repo: Optional[str] = None):
63
+ """
64
+ Initialize conversation analytics.
65
+
66
+ Args:
67
+ hf_dataset_repo: HF Dataset repo ID for persistence (e.g., "tao-shen/HuggingClaw-Cain-data")
68
+ """
69
+ self.hf_dataset_repo = hf_dataset_repo or os.getenv("OPENCLAW_DATASET_REPO")
70
+ self._lock = threading.Lock()
71
+
72
+ # Analytics data
73
+ self._conversations: Dict[str, ConversationMetrics] = {}
74
+ self._message_records: List[MessageRecord] = []
75
+ self._tool_usage: Dict[str, int] = defaultdict(int)
76
+ self._daily_stats: Dict[str, Dict[str, Any]] = {}
77
+
78
+ # Current conversation state
79
+ self._current_conversation_id: Optional[str] = None
80
+ self._current_conversation_start: Optional[float] = None
81
+ self._current_message_count: int = 0
82
+ self._current_tools: Dict[str, int] = defaultdict(int)
83
+ self._current_errors: int = 0
84
+
85
+ # Load existing analytics
86
+ self._ensure_dirs()
87
+ self._load_analytics()
88
+
89
+ def _ensure_dirs(self):
90
+ """Ensure analytics directories exist."""
91
+ ANALYTICS_DIR.mkdir(parents=True, exist_ok=True)
92
+
93
+ def _load_analytics(self):
94
+ """Load existing analytics from disk."""
95
+ if ANALYTICS_FILE.exists():
96
+ try:
97
+ with open(ANALYTICS_FILE, 'r') as f:
98
+ data = json.load(f)
99
+ for conv_id, conv_data in data.get("conversations", {}).items():
100
+ self._conversations[conv_id] = ConversationMetrics(**conv_data)
101
+ self._tool_usage = defaultdict(int, data.get("tool_usage", {}))
102
+ self._daily_stats = data.get("daily_stats", {})
103
+ except Exception as e:
104
+ print(f"[ConversationAnalytics] Failed to load analytics: {e}")
105
+
106
+ # Load conversation history
107
+ if CONVERSATION_HISTORY_FILE.exists():
108
+ try:
109
+ with open(CONVERSATION_HISTORY_FILE, 'r') as f:
110
+ for line in f:
111
+ if line.strip():
112
+ data = json.loads(line)
113
+ self._message_records.append(MessageRecord(**data))
114
+ except Exception as e:
115
+ print(f"[ConversationAnalytics] Failed to load history: {e}")
116
+
117
+ def _save_analytics(self):
118
+ """Save analytics to disk."""
119
+ try:
120
+ data = {
121
+ "last_updated": datetime.utcnow().isoformat() + "Z",
122
+ "conversations": {
123
+ k: asdict(v) for k, v in self._conversations.items()
124
+ },
125
+ "tool_usage": dict(self._tool_usage),
126
+ "daily_stats": self._daily_stats,
127
+ "total_conversations": len(self._conversations),
128
+ "total_messages": len(self._message_records)
129
+ }
130
+ with open(ANALYTICS_FILE, 'w') as f:
131
+ json.dump(data, f, indent=2)
132
+ except Exception as e:
133
+ print(f"[ConversationAnalytics] Failed to save analytics: {e}")
134
+
135
+ def _save_message_record(self, record: MessageRecord):
136
+ """Append a message record to history file."""
137
+ try:
138
+ with open(CONVERSATION_HISTORY_FILE, 'a') as f:
139
+ f.write(json.dumps(asdict(record)) + "\n")
140
+ except Exception as e:
141
+ print(f"[ConversationAnalytics] Failed to save message record: {e}")
142
+
143
+ def start_conversation(self, conversation_id: Optional[str] = None) -> str:
144
+ """
145
+ Start a new conversation tracking session.
146
+
147
+ Args:
148
+ conversation_id: Optional conversation ID (auto-generated if None)
149
+
150
+ Returns:
151
+ The conversation ID
152
+ """
153
+ with self._lock:
154
+ # End current conversation if any
155
+ if self._current_conversation_id:
156
+ self.end_conversation(status="interrupted")
157
+
158
+ # Generate new conversation ID
159
+ if conversation_id is None:
160
+ conversation_id = f"conv_{int(time.time() * 1000)}"
161
+
162
+ self._current_conversation_id = conversation_id
163
+ self._current_conversation_start = time.time()
164
+ self._current_message_count = 0
165
+ self._current_tools = defaultdict(int)
166
+ self._current_errors = 0
167
+
168
+ return conversation_id
169
+
170
+ def track_message(
171
+ self,
172
+ role: str,
173
+ content: str,
174
+ tools_used: List[str] = None,
175
+ response_time: float = 0.0,
176
+ success: bool = True
177
+ ):
178
+ """
179
+ Track a message in the current conversation.
180
+
181
+ Args:
182
+ role: Message role ("user" or "assistant")
183
+ content: Message content
184
+ tools_used: List of tools used in response
185
+ response_time: Response time in seconds
186
+ success: Whether the message was processed successfully
187
+ """
188
+ with self._lock:
189
+ if self._current_conversation_id is None:
190
+ self.start_conversation()
191
+
192
+ # Update current conversation stats
193
+ self._current_message_count += 1
194
+ if not success:
195
+ self._current_errors += 1
196
+
197
+ for tool in (tools_used or []):
198
+ self._current_tools[tool] += 1
199
+ self._tool_usage[tool] += 1
200
+
201
+ # Create message record
202
+ record = MessageRecord(
203
+ conversation_id=self._current_conversation_id,
204
+ timestamp=datetime.utcnow().isoformat() + "Z",
205
+ role=role,
206
+ content=content[:1000], # Truncate long messages
207
+ tools_used=tools_used or [],
208
+ response_time=response_time,
209
+ success=success
210
+ )
211
+ self._message_records.append(record)
212
+
213
+ # Save to history file
214
+ self._save_message_record(record)
215
+
216
+ def end_conversation(self, status: str = "completed") -> Optional[ConversationMetrics]:
217
+ """
218
+ End the current conversation and calculate metrics.
219
+
220
+ Args:
221
+ status: Conversation completion status ("completed", "error", "interrupted")
222
+
223
+ Returns:
224
+ The conversation metrics, or None if no conversation was active
225
+ """
226
+ with self._lock:
227
+ if self._current_conversation_id is None:
228
+ return None
229
+
230
+ # Calculate metrics
231
+ duration = time.time() - self._current_conversation_start
232
+ avg_response_time = 0.0
233
+
234
+ if self._message_records:
235
+ response_times = [
236
+ m.response_time for m in self._message_records
237
+ if m.role == "assistant" and m.response_time > 0
238
+ ]
239
+ if response_times:
240
+ avg_response_time = sum(response_times) / len(response_times)
241
+
242
+ metrics = ConversationMetrics(
243
+ conversation_id=self._current_conversation_id,
244
+ timestamp=datetime.utcnow().isoformat() + "Z",
245
+ message_count=self._current_message_count,
246
+ tool_calls=dict(self._current_tools),
247
+ avg_response_time=avg_response_time,
248
+ total_duration=duration,
249
+ status=status,
250
+ error_count=self._current_errors
251
+ )
252
+
253
+ # Store metrics
254
+ self._conversations[self._current_conversation_id] = metrics
255
+
256
+ # Update daily stats
257
+ today = datetime.utcnow().strftime("%Y-%m-%d")
258
+ if today not in self._daily_stats:
259
+ self._daily_stats[today] = {
260
+ "conversation_count": 0,
261
+ "message_count": 0,
262
+ "total_duration": 0.0,
263
+ "errors": 0
264
+ }
265
+
266
+ self._daily_stats[today]["conversation_count"] += 1
267
+ self._daily_stats[today]["message_count"] += self._current_message_count
268
+ self._daily_stats[today]["total_duration"] += duration
269
+ self._daily_stats[today]["errors"] += self._current_errors
270
+
271
+ # Reset current conversation
272
+ self._current_conversation_id = None
273
+ self._current_conversation_start = None
274
+ self._current_message_count = 0
275
+ self._current_tools = defaultdict(int)
276
+ self._current_errors = 0
277
+
278
+ # Save analytics
279
+ self._save_analytics()
280
+
281
+ return metrics
282
+
283
+ def get_stats(self) -> Dict[str, Any]:
284
+ """
285
+ Get current analytics statistics.
286
+
287
+ Returns:
288
+ Dictionary containing analytics statistics
289
+ """
290
+ with self._lock:
291
+ total_conversations = len(self._conversations)
292
+ total_messages = len(self._message_records)
293
+ total_errors = sum(c.error_count for c in self._conversations.values())
294
+
295
+ avg_response_time = 0.0
296
+ if self._conversations:
297
+ response_times = [c.avg_response_time for c in self._conversations.values() if c.avg_response_time > 0]
298
+ if response_times:
299
+ avg_response_time = sum(response_times) / len(response_times)
300
+
301
+ # Get recent conversations (last 10)
302
+ recent_conversations = sorted(
303
+ self._conversations.values(),
304
+ key=lambda c: c.timestamp,
305
+ reverse=True
306
+ )[:10]
307
+
308
+ return {
309
+ "total_conversations": total_conversations,
310
+ "total_messages": total_messages,
311
+ "total_errors": total_errors,
312
+ "avg_response_time": round(avg_response_time, 3),
313
+ "tool_usage": dict(sorted(self._tool_usage.items(), key=lambda x: x[1], reverse=True)),
314
+ "daily_stats": dict(sorted(self._daily_stats.items(), reverse=True)),
315
+ "recent_conversations": [asdict(c) for c in recent_conversations],
316
+ "current_conversation": self._current_conversation_id
317
+ }
318
+
319
+ def get_conversation_history(self, limit: int = 50) -> List[Dict[str, Any]]:
320
+ """
321
+ Get recent conversation history.
322
+
323
+ Args:
324
+ limit: Maximum number of message records to return
325
+
326
+ Returns:
327
+ List of message record dictionaries
328
+ """
329
+ with self._lock:
330
+ recent = self._message_records[-limit:]
331
+ return [asdict(r) for r in recent]
332
+
333
+ def get_summary(self) -> str:
334
+ """
335
+ Get a formatted summary of analytics.
336
+
337
+ Returns:
338
+ Formatted markdown summary
339
+ """
340
+ stats = self.get_stats()
341
+
342
+ lines = [
343
+ "## πŸ“Š Conversation Analytics",
344
+ "",
345
+ f"**Total Conversations:** {stats['total_conversations']}",
346
+ f"**Total Messages:** {stats['total_messages']}",
347
+ f"**Total Errors:** {stats['total_errors']}",
348
+ f"**Avg Response Time:** {stats['avg_response_time']}s",
349
+ "",
350
+ ]
351
+
352
+ if stats['tool_usage']:
353
+ lines.append("### Tool Usage")
354
+ lines.append("")
355
+ for tool, count in list(stats['tool_usage'].items())[:10]:
356
+ lines.append(f"- `{tool}`: {count} uses")
357
+ lines.append("")
358
+
359
+ if stats['daily_stats']:
360
+ lines.append("### Daily Stats (Last 7 Days)")
361
+ lines.append("")
362
+ for date, day_stats in list(stats['daily_stats'].items())[:7]:
363
+ lines.append(f"- **{date}:** {day_stats['conversation_count']} convs, {day_stats['message_count']} msgs")
364
+ lines.append("")
365
+
366
+ if stats['recent_conversations']:
367
+ lines.append("### Recent Conversations")
368
+ lines.append("")
369
+ for conv in stats['recent_conversations'][:5]:
370
+ status_emoji = {
371
+ "completed": "βœ…",
372
+ "error": "❌",
373
+ "interrupted": "⏸️"
374
+ }.get(conv['status'], "❓")
375
+ lines.append(
376
+ f"- {status_emoji} `{conv['conversation_id']}`: "
377
+ f"{conv['message_count']} msgs, "
378
+ f"{conv['avg_response_time']:.3f}s avg"
379
+ )
380
+
381
+ return "\n".join(lines)
382
+
383
+ def sync_to_dataset(self) -> Dict[str, Any]:
384
+ """
385
+ Sync analytics data to HF Dataset.
386
+
387
+ Returns:
388
+ Result dict with success status
389
+ """
390
+ if not self.hf_dataset_repo:
391
+ return {
392
+ "success": False,
393
+ "error": "No HF dataset repository configured"
394
+ }
395
+
396
+ try:
397
+ from huggingface_hub import HfApi
398
+
399
+ api = HfApi()
400
+ timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
401
+
402
+ # Upload analytics file
403
+ analytics_upload = api.upload_file(
404
+ path_or_fileobj=str(ANALYTICS_FILE),
405
+ path_in_repo=f"analytics/conversation_analytics_{timestamp}.json",
406
+ repo_id=self.hf_dataset_repo,
407
+ repo_type="dataset"
408
+ )
409
+
410
+ # Upload conversation history
411
+ history_upload = api.upload_file(
412
+ path_or_fileobj=str(CONVERSATION_HISTORY_FILE),
413
+ path_in_repo=f"analytics/conversation_history_{timestamp}.jsonl",
414
+ repo_id=self.hf_dataset_repo,
415
+ repo_type="dataset"
416
+ )
417
+
418
+ return {
419
+ "success": True,
420
+ "analytics_file": analytics_upload,
421
+ "history_file": history_upload,
422
+ "timestamp": timestamp,
423
+ "repo_id": self.hf_dataset_repo
424
+ }
425
+
426
+ except ImportError:
427
+ return {
428
+ "success": False,
429
+ "error": "huggingface_hub not available"
430
+ }
431
+ except Exception as e:
432
+ return {
433
+ "success": False,
434
+ "error": str(e)
435
+ }
436
+
437
+ def reset(self):
438
+ """Reset all analytics data."""
439
+ with self._lock:
440
+ self._conversations.clear()
441
+ self._message_records.clear()
442
+ self._tool_usage = defaultdict(int)
443
+ self._daily_stats.clear()
444
+ self._current_conversation_id = None
445
+ self._current_conversation_start = None
446
+ self._current_message_count = 0
447
+ self._current_tools = defaultdict(int)
448
+ self._current_errors = 0
449
+ self._save_analytics()
450
+
451
+
452
+ # Global instance
453
+ _global_analytics: Optional[ConversationAnalytics] = None
454
+
455
+
456
+ def get_analytics(hf_dataset_repo: Optional[str] = None) -> ConversationAnalytics:
457
+ """
458
+ Get the global conversation analytics instance.
459
+
460
+ Args:
461
+ hf_dataset_repo: Optional HF Dataset repo ID
462
+
463
+ Returns:
464
+ ConversationAnalytics instance
465
+ """
466
+ global _global_analytics
467
+ if _global_analytics is None:
468
+ _global_analytics = ConversationAnalytics(hf_dataset_repo)
469
+ return _global_analytics
470
+
471
+
472
+ if __name__ == "__main__":
473
+ # Test the analytics module
474
+ print("=== Conversation Analytics Test ===\n")
475
+
476
+ analytics = ConversationAnalytics()
477
+
478
+ # Start a conversation
479
+ conv_id = analytics.start_conversation("test_conv_001")
480
+ print(f"Started conversation: {conv_id}")
481
+
482
+ # Track some messages
483
+ analytics.track_message("user", "Hello, how are you?", [], 0, True)
484
+ time.sleep(0.1)
485
+ analytics.track_message("assistant", "I'm doing well!", ["conversation_process"], 0.1, True)
486
+ time.sleep(0.1)
487
+ analytics.track_message("user", "What can you do?", [], 0, True)
488
+ time.sleep(0.15)
489
+ analytics.track_message("assistant", "I can help with many things.", ["conversation_process", "memory_read"], 0.15, True)
490
+
491
+ # End conversation
492
+ metrics = analytics.end_conversation("completed")
493
+ print(f"\nConversation metrics: {metrics}")
494
+
495
+ # Get stats
496
+ stats = analytics.get_stats()
497
+ print(f"\nStats: {json.dumps(stats, indent=2, default=str)}")
498
+
499
+ # Get summary
500
+ print(f"\nSummary:\n{analytics.get_summary()}")
app.py CHANGED
@@ -15,6 +15,7 @@ import sys
15
  import json
16
  import asyncio
17
  import threading
 
18
  from pathlib import Path
19
  from datetime import datetime
20
  from typing import Dict, Any, List, Optional, Tuple
@@ -200,6 +201,18 @@ except ImportError as e:
200
  print(f"[Agent Office] Warning: rbac not available - {e}")
201
  RBAC_AVAILABLE = False
202
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  # ========== Data Loading Functions ==========
204
 
205
  def load_cain_status() -> Dict[str, Any]:
@@ -390,6 +403,10 @@ def chat_with_brain(
390
  if not message.strip():
391
  return history, "Please enter a message."
392
 
 
 
 
 
393
  # Emit thinking event
394
  emit_agent_thought(
395
  ThoughtEventType.THINKING,
@@ -433,6 +450,7 @@ def chat_with_brain(
433
  )
434
 
435
  result = brain_instance.execute_tool("conversation_process", message)
 
436
 
437
  if result.get("success"):
438
  response = result.get("response", f"Processed: {message}")
@@ -468,6 +486,13 @@ def chat_with_brain(
468
  )
469
 
470
  history.append((message, response))
 
 
 
 
 
 
 
471
  return history, status_msg
472
 
473
  except Exception as e:
@@ -480,6 +505,12 @@ def chat_with_brain(
480
  metadata={"exception": str(e), "type": type(e).__name__}
481
  )
482
 
 
 
 
 
 
 
483
  return history, f"❌ Exception: {str(e)}"
484
 
485
 
@@ -512,6 +543,55 @@ def get_brain_info() -> str:
512
  return f"Error getting brain info: {str(e)}"
513
 
514
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
515
  # ========== Refresh Functions ==========
516
 
517
  def refresh_status() -> str:
@@ -526,19 +606,30 @@ def refresh_agent_directory() -> Tuple[List[List[str]], str]:
526
  return format_agent_directory(registry), f"Last updated: {datetime.utcnow().isoformat()}Z"
527
 
528
 
529
- def refresh_all() -> Tuple[str, List[List[str]], str, str]:
530
  """Refresh all dynamic components"""
531
  status = load_cain_status()
532
  registry = load_agent_registry()
533
  brain_info = get_brain_info()
534
  timestamp = f"Last updated: {datetime.utcnow().isoformat()}Z"
535
 
536
- return (
537
- format_status_header(status),
538
- format_agent_directory(registry),
539
- brain_info,
540
- timestamp
541
- )
 
 
 
 
 
 
 
 
 
 
 
542
 
543
 
544
  # ========== Custom CSS ==========
@@ -676,6 +767,24 @@ def create_agent_office():
676
  label="Current Brain Status"
677
  )
678
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
679
  # ========== Footer ==========
680
  gr.Markdown("---")
681
  with gr.Row():
@@ -727,21 +836,55 @@ def create_agent_office():
727
  cain_status_display,
728
  agent_directory_table,
729
  brain_info_display,
730
- last_updated_display
 
731
  ]
732
  )
733
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
734
  # Auto-refresh on load
735
- app.load(
736
- fn=refresh_all,
737
- inputs=[],
738
- outputs=[
739
- cain_status_display,
740
- agent_directory_table,
741
- brain_info_display,
742
- last_updated_display
743
- ]
744
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
745
 
746
  return app
747
 
@@ -783,6 +926,36 @@ def create_agent_office_with_ws():
783
  """Get agent registry as JSON."""
784
  return load_agent_registry()
785
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
786
  # Create Gradio app
787
  gradio_app = create_agent_office()
788
 
 
15
  import json
16
  import asyncio
17
  import threading
18
+ import time
19
  from pathlib import Path
20
  from datetime import datetime
21
  from typing import Dict, Any, List, Optional, Tuple
 
201
  print(f"[Agent Office] Warning: rbac not available - {e}")
202
  RBAC_AVAILABLE = False
203
 
204
+ # Import conversation analytics
205
+ try:
206
+ from conversation_analytics import get_analytics, ConversationAnalytics
207
+ ANALYTICS_AVAILABLE = True
208
+ # Initialize analytics with HF dataset repo from env
209
+ hf_dataset = os.getenv("OPENCLAW_DATASET_REPO", "tao-shen/HuggingClaw-Cain-data")
210
+ analytics = get_analytics(hf_dataset)
211
+ except ImportError as e:
212
+ print(f"[Agent Office] Warning: conversation_analytics not available - {e}")
213
+ ANALYTICS_AVAILABLE = False
214
+ analytics = None
215
+
216
  # ========== Data Loading Functions ==========
217
 
218
  def load_cain_status() -> Dict[str, Any]:
 
403
  if not message.strip():
404
  return history, "Please enter a message."
405
 
406
+ # Track start time for analytics
407
+ start_time = time.time()
408
+ tools_used = []
409
+
410
  # Emit thinking event
411
  emit_agent_thought(
412
  ThoughtEventType.THINKING,
 
450
  )
451
 
452
  result = brain_instance.execute_tool("conversation_process", message)
453
+ tools_used = ["conversation_process"]
454
 
455
  if result.get("success"):
456
  response = result.get("response", f"Processed: {message}")
 
486
  )
487
 
488
  history.append((message, response))
489
+
490
+ # Track analytics for user and assistant messages
491
+ response_time = time.time() - start_time
492
+ if ANALYTICS_AVAILABLE and analytics:
493
+ analytics.track_message("user", message, [], 0, True)
494
+ analytics.track_message("assistant", response, tools_used, response_time, "Error" not in response)
495
+
496
  return history, status_msg
497
 
498
  except Exception as e:
 
505
  metadata={"exception": str(e), "type": type(e).__name__}
506
  )
507
 
508
+ # Track error in analytics
509
+ response_time = time.time() - start_time
510
+ if ANALYTICS_AVAILABLE and analytics:
511
+ analytics.track_message("user", message, [], 0, True)
512
+ analytics.track_message("assistant", error_response, tools_used, response_time, False)
513
+
514
  return history, f"❌ Exception: {str(e)}"
515
 
516
 
 
543
  return f"Error getting brain info: {str(e)}"
544
 
545
 
546
+ # ========== Analytics Functions ==========
547
+
548
+ def get_analytics_summary() -> str:
549
+ """Get the conversation analytics summary"""
550
+ if not ANALYTICS_AVAILABLE or analytics is None:
551
+ return "### πŸ“Š Analytics\n\nAnalytics module not available."
552
+ return analytics.get_summary()
553
+
554
+
555
+ def refresh_analytics() -> str:
556
+ """Refresh the analytics display"""
557
+ return get_analytics_summary()
558
+
559
+
560
+ def sync_analytics_to_dataset() -> str:
561
+ """Sync analytics to HF Dataset"""
562
+ if not ANALYTICS_AVAILABLE or analytics is None:
563
+ return "❌ Analytics not available"
564
+
565
+ result = analytics.sync_to_dataset()
566
+ if result.get("success"):
567
+ return f"βœ… Synced to dataset `{result['repo_id']}` at `{result['timestamp']}`"
568
+ else:
569
+ return f"❌ Sync failed: {result.get('error', 'Unknown error')}"
570
+
571
+
572
+ def reset_analytics_data() -> str:
573
+ """Reset all analytics data"""
574
+ if not ANALYTICS_AVAILABLE or analytics is None:
575
+ return "❌ Analytics not available"
576
+
577
+ analytics.reset()
578
+ return "βœ… Analytics data has been reset"
579
+
580
+
581
+ def get_analytics_stats_json() -> Dict[str, Any]:
582
+ """Get analytics stats as JSON for API"""
583
+ if not ANALYTICS_AVAILABLE or analytics is None:
584
+ return {"error": "Analytics not available"}
585
+ return analytics.get_stats()
586
+
587
+
588
+ def get_conversation_history_json(limit: int = 50) -> List[Dict[str, Any]]:
589
+ """Get conversation history as JSON for API"""
590
+ if not ANALYTICS_AVAILABLE or analytics is None:
591
+ return [{"error": "Analytics not available"}]
592
+ return analytics.get_conversation_history(limit)
593
+
594
+
595
  # ========== Refresh Functions ==========
596
 
597
  def refresh_status() -> str:
 
606
  return format_agent_directory(registry), f"Last updated: {datetime.utcnow().isoformat()}Z"
607
 
608
 
609
+ def refresh_all() -> Tuple:
610
  """Refresh all dynamic components"""
611
  status = load_cain_status()
612
  registry = load_agent_registry()
613
  brain_info = get_brain_info()
614
  timestamp = f"Last updated: {datetime.utcnow().isoformat()}Z"
615
 
616
+ # Include analytics if available
617
+ if ANALYTICS_AVAILABLE:
618
+ analytics_summary = get_analytics_summary()
619
+ return (
620
+ format_status_header(status),
621
+ format_agent_directory(registry),
622
+ brain_info,
623
+ analytics_summary,
624
+ timestamp
625
+ )
626
+ else:
627
+ return (
628
+ format_status_header(status),
629
+ format_agent_directory(registry),
630
+ brain_info,
631
+ timestamp
632
+ )
633
 
634
 
635
  # ========== Custom CSS ==========
 
767
  label="Current Brain Status"
768
  )
769
 
770
+ # ========== Conversation Analytics ==========
771
+ if ANALYTICS_AVAILABLE:
772
+ with gr.Accordion("πŸ“Š Conversation Analytics", open=False):
773
+ analytics_display = gr.Markdown(
774
+ value=get_analytics_summary(),
775
+ label="Analytics Summary"
776
+ )
777
+ refresh_analytics_btn = gr.Button("πŸ”„ Refresh Analytics", size="sm")
778
+
779
+ with gr.Row():
780
+ sync_analytics_btn = gr.Button("πŸ’Ύ Sync to Dataset", size="sm")
781
+ reset_analytics_btn = gr.Button("πŸ—‘οΈ Reset Analytics", size="sm", variant="stop")
782
+
783
+ analytics_status = gr.Markdown(
784
+ value="",
785
+ label="Sync Status"
786
+ )
787
+
788
  # ========== Footer ==========
789
  gr.Markdown("---")
790
  with gr.Row():
 
836
  cain_status_display,
837
  agent_directory_table,
838
  brain_info_display,
839
+ analytics_display if ANALYTICS_AVAILABLE else last_updated_display,
840
+ last_updated_display if ANALYTICS_AVAILABLE else brain_info_display
841
  ]
842
  )
843
 
844
+ # Analytics handlers (if available)
845
+ if ANALYTICS_AVAILABLE:
846
+ refresh_analytics_btn.click(
847
+ fn=refresh_analytics,
848
+ inputs=[],
849
+ outputs=[analytics_display]
850
+ )
851
+
852
+ sync_analytics_btn.click(
853
+ fn=sync_analytics_to_dataset,
854
+ inputs=[],
855
+ outputs=[analytics_status]
856
+ )
857
+
858
+ reset_analytics_btn.click(
859
+ fn=reset_analytics_data,
860
+ inputs=[],
861
+ outputs=[analytics_status]
862
+ )
863
+
864
  # Auto-refresh on load
865
+ if ANALYTICS_AVAILABLE:
866
+ app.load(
867
+ fn=refresh_all,
868
+ inputs=[],
869
+ outputs=[
870
+ cain_status_display,
871
+ agent_directory_table,
872
+ brain_info_display,
873
+ analytics_display,
874
+ last_updated_display
875
+ ]
876
+ )
877
+ else:
878
+ app.load(
879
+ fn=refresh_all,
880
+ inputs=[],
881
+ outputs=[
882
+ cain_status_display,
883
+ agent_directory_table,
884
+ brain_info_display,
885
+ last_updated_display
886
+ ]
887
+ )
888
 
889
  return app
890
 
 
926
  """Get agent registry as JSON."""
927
  return load_agent_registry()
928
 
929
+ # ========== Analytics API Endpoints ==========
930
+ if ANALYTICS_AVAILABLE:
931
+ @fastapi_app.get("/api/analytics/stats")
932
+ async def api_analytics_stats():
933
+ """Get conversation analytics statistics."""
934
+ return get_analytics_stats_json()
935
+
936
+ @fastapi_app.get("/api/analytics/summary")
937
+ async def api_analytics_summary():
938
+ """Get analytics summary as markdown."""
939
+ return {"summary": get_analytics_summary()}
940
+
941
+ @fastapi_app.get("/api/analytics/history")
942
+ async def api_analytics_history(limit: int = 50):
943
+ """Get conversation history."""
944
+ return get_conversation_history_json(limit)
945
+
946
+ @fastapi_app.post("/api/analytics/sync")
947
+ async def api_analytics_sync():
948
+ """Sync analytics to HF Dataset."""
949
+ result = analytics.sync_to_dataset()
950
+ return result
951
+
952
+ @fastapi_app.post("/api/analytics/reset")
953
+ async def api_analytics_reset():
954
+ """Reset analytics data."""
955
+ if analytics:
956
+ analytics.reset()
957
+ return {"status": "reset"}
958
+
959
  # Create Gradio app
960
  gradio_app = create_agent_office()
961