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

1""" 

2MoAI-ADK Session Analyzer 

3 

4Analyzes Claude Code session logs to generate data-driven improvement suggestions 

5 

6This module provides the SessionAnalyzer class for analyzing Claude Code session logs 

7and generating improvement suggestions based on usage patterns. 

8""" 

9 

10import json 

11from collections import defaultdict 

12from datetime import datetime, timedelta 

13from pathlib import Path 

14from typing import Any, Dict, Optional, cast 

15 

16 

17class SessionAnalyzer: 

18 """Claude Code session log analyzer""" 

19 

20 def __init__(self, days_back: int = 7, verbose: bool = False): 

21 """ 

22 Initialize SessionAnalyzer 

23 

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 

31 

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 } 

45 

46 self.sessions_data: list[Dict[str, Any]] = [] 

47 

48 def parse_sessions(self) -> Dict[str, Any]: 

49 """ 

50 Parse all session logs from the last N days 

51 

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 

59 

60 cutoff_date = datetime.now() - timedelta(days=self.days_back) 

61 

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")) 

66 

67 if self.verbose: 

68 print(f"Found {len(session_files)} session files") 

69 

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 

74 

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 ) 

92 

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}") 

106 

107 self.patterns["total_sessions"] = len(self.sessions_data) 

108 return self.patterns 

109 

110 def _analyze_session(self, session: Dict[str, Any]): 

111 """ 

112 Analyze individual session 

113 

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() 

121 

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 

133 

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 

149 

150 # Track session summaries as events 

151 self.patterns["total_events"] = cast(int, self.patterns["total_events"]) + 1 

152 return 

153 

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 ) 

159 

160 has_error = False 

161 

162 for event in events: 

163 event_type = event.get("type", "unknown") 

164 

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 

170 

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 

179 

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 

187 

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 

196 

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 

206 

207 if has_error: 

208 self.patterns["failed_sessions"] = ( 

209 cast(int, self.patterns["failed_sessions"]) + 1 

210 ) 

211 

212 def generate_report(self) -> str: 

213 """ 

214 Generate markdown report 

215 

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 ) 

228 

229 report = f"""# MoAI-ADK Session Meta-Analysis Report 

230 

231**Generated at**: {timestamp} 

232**Analysis period**: Last {self.days_back} days 

233**Analysis scope**: `~/.claude/projects/` 

234 

235--- 

236 

237## Overall Metrics 

238 

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} | 

246 

247--- 

248 

249## Tool Usage Patterns (Top 10) 

250 

251""" 

252 

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) 

256 

257 report += "| Tool | Usage Count |\n|------|----------|\n" 

258 for tool, count in sorted_tools[:10]: 

259 report += f"| `{tool}` | {count} |\n" 

260 

261 # Tool error patterns 

262 report += "\n## Tool Error Patterns (Top 5)\n\n" 

263 

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" 

276 

277 # Hook failure analysis 

278 report += "\n## Hook Failure Analysis\n\n" 

279 

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" 

290 

291 # Permission request analysis 

292 report += "\n## Permission Request Patterns\n\n" 

293 

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" 

308 

309 # Improvement suggestions 

310 report += "\n## Improvement Suggestions\n\n" 

311 report += self._generate_suggestions() 

312 

313 return report 

314 

315 def _generate_suggestions(self) -> str: 

316 """ 

317 Generate improvement suggestions based on patterns 

318 

319 Returns: 

320 Formatted suggestions string 

321 """ 

322 suggestions: list[str] = [] 

323 

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 ) 

339 

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 ) 

353 

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 ) 

368 

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 ) 

377 

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 ) 

385 

386 if not suggestions: 

387 suggestions.append( 

388 "✅ No major issues detected\n" 

389 " - Current settings and rules are working well" 

390 ) 

391 

392 return "\n\n".join(suggestions) 

393 

394 def save_report( 

395 self, output_path: Optional[Path] = None, project_path: Optional[Path] = None 

396 ) -> Path: 

397 """ 

398 Save report to file 

399 

400 Args: 

401 output_path: Custom output file path (optional) 

402 project_path: Project root path (defaults to current working directory) 

403 

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() 

410 

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" 

414 

415 report = self.generate_report() 

416 output_path.write_text(report, encoding="utf-8") 

417 

418 if self.verbose: 

419 print(f"📄 Report saved: {output_path}") 

420 

421 return output_path 

422 

423 def get_metrics(self) -> Dict[str, Any]: 

424 """ 

425 Get analysis metrics as dictionary 

426 

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 

438 

439 return self.patterns.copy()