Coverage for src / moai_adk / core / analysis / session_analyzer.py: 0.00%
178 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-20 20:52 +0900
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-20 20:52 +0900
1"""
2MoAI-ADK Session Analyzer
4Analyzes Claude Code session logs to generate data-driven improvement suggestions
6This module provides the SessionAnalyzer class for analyzing Claude Code session logs
7and generating improvement suggestions based on usage patterns.
8"""
10import json
11from collections import defaultdict
12from datetime import datetime, timedelta
13from pathlib import Path
14from typing import Any, Dict, Optional, cast
17class SessionAnalyzer:
18 """Claude Code session log analyzer"""
20 def __init__(self, days_back: int = 7, verbose: bool = False):
21 """
22 Initialize SessionAnalyzer
24 Args:
25 days_back: Number of days to analyze (default: 7)
26 verbose: Enable verbose output (default: False)
27 """
28 self.claude_projects = Path.home() / ".claude" / "projects"
29 self.days_back = days_back
30 self.verbose = verbose
32 self.patterns = {
33 "total_sessions": 0,
34 "total_events": 0,
35 "tool_usage": defaultdict(int),
36 "tool_failures": defaultdict(int),
37 "error_patterns": defaultdict(int),
38 "permission_requests": defaultdict(int),
39 "hook_failures": defaultdict(int),
40 "command_frequency": defaultdict(int),
41 "average_session_length": 0,
42 "success_rate": 0.0,
43 "failed_sessions": 0,
44 }
46 self.sessions_data: list[Dict[str, Any]] = []
48 def parse_sessions(self) -> Dict[str, Any]:
49 """
50 Parse all session logs from the last N days
52 Returns:
53 Dictionary containing analysis patterns and metrics
54 """
55 if not self.claude_projects.exists():
56 if self.verbose:
57 print(f"⚠️ Claude projects directory not found: {self.claude_projects}")
58 return self.patterns
60 cutoff_date = datetime.now() - timedelta(days=self.days_back)
62 # Look for both session-*.json and UUID.jsonl files
63 session_files: list[Path] = []
64 session_files.extend(self.claude_projects.glob("*/session-*.json"))
65 session_files.extend(self.claude_projects.glob("*/*.jsonl"))
67 if self.verbose:
68 print(f"Found {len(session_files)} session files")
70 for session_file in session_files:
71 # Check file modification time
72 if datetime.fromtimestamp(session_file.stat().st_mtime) < cutoff_date:
73 continue
75 try:
76 # Handle both JSON and JSONL formats
77 if session_file.suffix == ".jsonl":
78 # JSONL format: read line by line
79 sessions = []
80 with open(session_file, encoding="utf-8") as f:
81 for line_num, line in enumerate(f, 1):
82 line = line.strip()
83 if line:
84 try:
85 session = json.loads(line)
86 sessions.append(session)
87 except json.JSONDecodeError as e:
88 if self.verbose:
89 print(
90 f"⚠️ Error reading line {line_num} in {session_file}: {e}"
91 )
93 # Analyze each session from the JSONL file
94 for session in sessions:
95 self._analyze_session(session)
96 self.sessions_data.append(session)
97 else:
98 # JSON format: single session per file
99 with open(session_file, encoding="utf-8") as f:
100 session = json.load(f)
101 self._analyze_session(session)
102 self.sessions_data.append(session)
103 except (json.JSONDecodeError, IOError) as e:
104 if self.verbose:
105 print(f"⚠️ Error reading {session_file}: {e}")
107 self.patterns["total_sessions"] = len(self.sessions_data)
108 return self.patterns
110 def _analyze_session(self, session: Dict[str, Any]):
111 """
112 Analyze individual session
114 Args:
115 session: Session data dictionary from Claude Code
116 """
117 # Handle session summary format (current JSONL format)
118 if session.get("type") == "summary":
119 # Count session types by summary content
120 summary = cast(str, session.get("summary", "")).lower()
122 # Simple analysis of session summaries
123 if any(
124 keyword in summary for keyword in ["error", "fail", "issue", "problem"]
125 ):
126 self.patterns["failed_sessions"] = (
127 cast(int, self.patterns["failed_sessions"]) + 1
128 )
129 tool_failures = cast(
130 defaultdict[str, int], self.patterns["tool_failures"]
131 )
132 tool_failures["session_error_in_summary"] += 1
134 # Extract potential tool usage from summary
135 tool_keywords = [
136 "test",
137 "build",
138 "deploy",
139 "analyze",
140 "create",
141 "update",
142 "fix",
143 "check",
144 ]
145 tool_usage = cast(defaultdict[str, int], self.patterns["tool_usage"])
146 for keyword in tool_keywords:
147 if keyword in summary:
148 tool_usage[f"summary_{keyword}"] += 1
150 # Track session summaries as events
151 self.patterns["total_events"] = cast(int, self.patterns["total_events"]) + 1
152 return
154 # Handle detailed event format (legacy session-*.json format)
155 events = cast(list[Dict[str, Any]], session.get("events", []))
156 self.patterns["total_events"] = cast(int, self.patterns["total_events"]) + len(
157 events
158 )
160 has_error = False
162 for event in events:
163 event_type = event.get("type", "unknown")
165 # Extract tool usage patterns
166 if event_type == "tool_call":
167 tool_name = cast(str, event.get("toolName", "unknown")).split("(")[0]
168 tool_usage = cast(defaultdict[str, int], self.patterns["tool_usage"])
169 tool_usage[tool_name] += 1
171 # Tool error patterns
172 elif event_type == "tool_error":
173 error_msg = cast(str, event.get("error", "unknown error"))
174 tool_failures = cast(
175 defaultdict[str, int], self.patterns["tool_failures"]
176 )
177 tool_failures[error_msg[:50]] += 1 # First 50 characters
178 has_error = True
180 # Permission requests
181 elif event_type == "permission_request":
182 perm_type = cast(str, event.get("permission_type", "unknown"))
183 perm_requests = cast(
184 defaultdict[str, int], self.patterns["permission_requests"]
185 )
186 perm_requests[perm_type] += 1
188 # Hook failures
189 elif event_type == "hook_failure":
190 hook_name = cast(str, event.get("hook_name", "unknown"))
191 hook_failures = cast(
192 defaultdict[str, int], self.patterns["hook_failures"]
193 )
194 hook_failures[hook_name] += 1
195 has_error = True
197 # Command usage
198 if "command" in event:
199 cmd_str = cast(str, event.get("command", "")).split()
200 if cmd_str:
201 cmd = cmd_str[0]
202 cmd_freq = cast(
203 defaultdict[str, int], self.patterns["command_frequency"]
204 )
205 cmd_freq[cmd] += 1
207 if has_error:
208 self.patterns["failed_sessions"] = (
209 cast(int, self.patterns["failed_sessions"]) + 1
210 )
212 def generate_report(self) -> str:
213 """
214 Generate markdown report
216 Returns:
217 Formatted markdown report string
218 """
219 timestamp = datetime.now().isoformat()
220 total_sessions = cast(int, self.patterns["total_sessions"])
221 failed_sessions = cast(int, self.patterns["failed_sessions"])
222 total_events = cast(int, self.patterns["total_events"])
223 success_rate = (
224 ((total_sessions - failed_sessions) / total_sessions * 100)
225 if total_sessions > 0
226 else 0
227 )
229 report = f"""# MoAI-ADK Session Meta-Analysis Report
231**Generated at**: {timestamp}
232**Analysis period**: Last {self.days_back} days
233**Analysis scope**: `~/.claude/projects/`
235---
237## Overall Metrics
239| Metric | Value |
240|--------|-------|
241| **Total sessions** | {total_sessions} |
242| **Total events** | {total_events} |
243| **Successful sessions** | {total_sessions - failed_sessions} ({success_rate:.1f}%) |
244| **Failed sessions** | {failed_sessions} ({100 - success_rate:.1f}%) |
245| **Average session length** | {total_events / total_sessions if total_sessions > 0 else 0:.1f} |
247---
249## Tool Usage Patterns (Top 10)
251"""
253 # Top tool usage
254 tool_usage = cast(defaultdict[str, int], self.patterns["tool_usage"])
255 sorted_tools = sorted(tool_usage.items(), key=lambda x: x[1], reverse=True)
257 report += "| Tool | Usage Count |\n|------|----------|\n"
258 for tool, count in sorted_tools[:10]:
259 report += f"| `{tool}` | {count} |\n"
261 # Tool error patterns
262 report += "\n## Tool Error Patterns (Top 5)\n\n"
264 tool_failures = cast(defaultdict[str, int], self.patterns["tool_failures"])
265 if tool_failures:
266 sorted_errors = sorted(
267 tool_failures.items(),
268 key=lambda x: x[1],
269 reverse=True,
270 )
271 report += "| Error | Occurrence Count |\n|--------|----------|\n"
272 for error, count in sorted_errors[:5]:
273 report += f"| {error}... | {count} |\n"
274 else:
275 report += "✅ No tool errors\n"
277 # Hook failure analysis
278 report += "\n## Hook Failure Analysis\n\n"
280 hook_failures = cast(defaultdict[str, int], self.patterns["hook_failures"])
281 if hook_failures:
282 for hook, count in sorted(
283 hook_failures.items(),
284 key=lambda x: x[1],
285 reverse=True,
286 ):
287 report += f"- **{hook}**: {count} times\n"
288 else:
289 report += "✅ No hook failures\n"
291 # Permission request analysis
292 report += "\n## Permission Request Patterns\n\n"
294 perm_requests = cast(
295 defaultdict[str, int], self.patterns["permission_requests"]
296 )
297 if perm_requests:
298 sorted_perms = sorted(
299 perm_requests.items(),
300 key=lambda x: x[1],
301 reverse=True,
302 )
303 report += "| Permission Type | Request Count |\n|---------|----------|\n"
304 for perm, count in sorted_perms:
305 report += f"| {perm} | {count} |\n"
306 else:
307 report += "✅ No permission requests\n"
309 # Improvement suggestions
310 report += "\n## Improvement Suggestions\n\n"
311 report += self._generate_suggestions()
313 return report
315 def _generate_suggestions(self) -> str:
316 """
317 Generate improvement suggestions based on patterns
319 Returns:
320 Formatted suggestions string
321 """
322 suggestions: list[str] = []
324 # High permission requests - review permission settings
325 perm_requests = cast(
326 defaultdict[str, int], self.patterns["permission_requests"]
327 )
328 if perm_requests:
329 top_perm = max(
330 perm_requests.items(),
331 key=lambda x: x[1],
332 )
333 if top_perm[1] >= 5:
334 suggestions.append(
335 f"Permission: **{top_perm[0]}** requested frequently ({top_perm[1]} times)\n"
336 f" - Review `permissions` in `.claude/settings.json`\n"
337 f" - Change `allow` to `ask` or add new Bash tool rules"
338 )
340 # Tool failure patterns - add fallback strategies
341 tool_failures = cast(defaultdict[str, int], self.patterns["tool_failures"])
342 if tool_failures:
343 top_error = max(
344 tool_failures.items(),
345 key=lambda x: x[1],
346 )
347 if top_error[1] >= 3:
348 suggestions.append(
349 f"Tool error: **{top_error[0]}...** ({top_error[1]} times)\n"
350 f" - Add fallback strategy to CLAUDE.md\n"
351 f" - Example: 'If X error occurs, try Y'"
352 )
354 # Hook failures - review hook logic
355 hook_failures = cast(defaultdict[str, int], self.patterns["hook_failures"])
356 if hook_failures:
357 for hook, count in sorted(
358 hook_failures.items(),
359 key=lambda x: x[1],
360 reverse=True,
361 )[:3]:
362 if count >= 2:
363 suggestions.append(
364 f"Hook failure: **{hook}** ({count} times)\n"
365 f" - Debug `.claude/hooks/alfred/{hook}.py`\n"
366 f" - Check timeouts, permissions, and file paths"
367 )
369 # Low success rate - general diagnosis
370 total_sessions = cast(int, self.patterns["total_sessions"])
371 failed_sessions = cast(int, self.patterns["failed_sessions"])
372 success_rate = (
373 ((total_sessions - failed_sessions) / total_sessions * 100)
374 if total_sessions > 0
375 else 0
376 )
378 if success_rate < 80 and total_sessions >= 5:
379 suggestions.append(
380 f"Low success rate: **{success_rate:.1f}%**\n"
381 f" - Review recent session logs in detail\n"
382 f" - Re-evaluate rules and constraints in CLAUDE.md\n"
383 f" - Verify context synchronization between Alfred and Sub-agents"
384 )
386 if not suggestions:
387 suggestions.append(
388 "✅ No major issues detected\n"
389 " - Current settings and rules are working well"
390 )
392 return "\n\n".join(suggestions)
394 def save_report(
395 self, output_path: Optional[Path] = None, project_path: Optional[Path] = None
396 ) -> Path:
397 """
398 Save report to file
400 Args:
401 output_path: Custom output file path (optional)
402 project_path: Project root path (defaults to current working directory)
404 Returns:
405 Path to the saved report file
406 """
407 if output_path is None:
408 if project_path is None:
409 project_path = Path.cwd()
411 output_dir = project_path / ".moai" / "reports"
412 output_dir.mkdir(parents=True, exist_ok=True)
413 output_path = output_dir / f"daily-{datetime.now().strftime('%Y-%m-%d')}.md"
415 report = self.generate_report()
416 output_path.write_text(report, encoding="utf-8")
418 if self.verbose:
419 print(f"📄 Report saved: {output_path}")
421 return output_path
423 def get_metrics(self) -> Dict[str, Any]:
424 """
425 Get analysis metrics as dictionary
427 Returns:
428 Dictionary containing analysis metrics
429 """
430 total_sessions = cast(int, self.patterns["total_sessions"])
431 if total_sessions > 0:
432 failed_sessions = cast(int, self.patterns["failed_sessions"])
433 total_events = cast(int, self.patterns["total_events"])
434 self.patterns["success_rate"] = (
435 (total_sessions - failed_sessions) / total_sessions * 100
436 )
437 self.patterns["average_session_length"] = total_events / total_sessions
439 return self.patterns.copy()