Coverage for src/local_deep_research/error_handling/error_reporter.py: 90%

81 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-03 23:15 +0000

1""" 

2ErrorReporter - Main error categorization and handling logic 

3""" 

4 

5import re 

6from enum import Enum 

7from typing import Any, Dict, Optional 

8 

9from loguru import logger 

10 

11 

12class ErrorCategory(Enum): 

13 """Categories of errors that can occur during research""" 

14 

15 CONNECTION_ERROR = "connection_error" 

16 MODEL_ERROR = "model_error" 

17 SEARCH_ERROR = "search_error" 

18 SYNTHESIS_ERROR = "synthesis_error" 

19 FILE_ERROR = "file_error" 

20 RATE_LIMIT_ERROR = "rate_limit_error" 

21 UNKNOWN_ERROR = "unknown_error" 

22 

23 

24class ErrorReporter: 

25 """ 

26 Analyzes and categorizes errors to provide better user feedback 

27 """ 

28 

29 def __init__(self): 

30 self.error_patterns = { 

31 ErrorCategory.CONNECTION_ERROR: [ 

32 r"POST predict.*EOF", 

33 r"Connection refused", 

34 r"timeout", 

35 r"Connection.*failed", 

36 r"HTTP error \d+", 

37 r"network.*error", 

38 r"\[Errno 111\]", 

39 r"host\.docker\.internal", 

40 r"host.*localhost.*Docker", 

41 r"127\.0\.0\.1.*Docker", 

42 r"localhost.*1234.*Docker", 

43 r"LM.*Studio.*Docker.*Mac", 

44 # OpenAI-compatible endpoint tokens (#3878) 

45 r"Error type: openai_connection_refused", 

46 r"Error type: openai_timeout", 

47 ], 

48 ErrorCategory.MODEL_ERROR: [ 

49 r"Model.*not found", 

50 r"Invalid.*model", 

51 r"Ollama.*not available", 

52 r"API key.*invalid", 

53 r"Authentication.*error", 

54 r"max_workers must be greater than 0", 

55 r"TypeError.*Context.*Size", 

56 r"'<' not supported between", 

57 r"No auth credentials found", 

58 r"401.*API key", 

59 # OpenAI-compatible endpoint tokens (#3878) 

60 r"Error type: openai_auth", 

61 r"Error type: openai_permission_denied", 

62 r"Error type: openai_model_not_found", 

63 r"Error type: openai_bad_request", 

64 r"Error type: openai_unknown", 

65 ], 

66 ErrorCategory.RATE_LIMIT_ERROR: [ 

67 r"429.*resource.*exhausted", 

68 r"429.*too many requests", 

69 r"rate limit", 

70 r"rate_limit", 

71 r"ratelimit", 

72 r"quota.*exceeded", 

73 r"resource.*exhausted.*quota", 

74 r"threshold.*requests", 

75 r"LLM rate limit", 

76 r"API rate limit", 

77 r"maximum.*requests.*minute", 

78 r"maximum.*requests.*hour", 

79 # OpenAI-compatible endpoint token (#3878 follow-up) 

80 r"Error type: openai_rate_limit", 

81 ], 

82 ErrorCategory.SEARCH_ERROR: [ 

83 r"Search.*failed", 

84 r"No search results", 

85 r"Search engine.*error", 

86 r"The search is longer than 256 characters", 

87 r"Failed to create search engine", 

88 r"could not be found", 

89 r"GitHub API error", 

90 r"database.*locked", 

91 ], 

92 ErrorCategory.SYNTHESIS_ERROR: [ 

93 r"Error.*synthesis", 

94 r"Failed.*generate", 

95 r"Synthesis.*timeout", 

96 r"detailed.*report.*stuck", 

97 r"report.*taking.*long", 

98 r"progress.*100.*stuck", 

99 ], 

100 ErrorCategory.FILE_ERROR: [ 

101 r"Permission denied", 

102 r"File.*not found", 

103 r"Cannot write.*file", 

104 r"Disk.*full", 

105 r"No module named.*local_deep_research", 

106 r"HTTP error 404.*research results", 

107 r"Attempt to write readonly database", 

108 ], 

109 } 

110 

111 def categorize_error(self, error_message: str) -> ErrorCategory: 

112 """ 

113 Categorize an error based on its message 

114 

115 Args: 

116 error_message: The error message to categorize 

117 

118 Returns: 

119 ErrorCategory: The categorized error type 

120 """ 

121 error_message = str(error_message).lower() 

122 

123 for category, patterns in self.error_patterns.items(): 

124 for pattern in patterns: 

125 if re.search(pattern.lower(), error_message): 

126 logger.debug( 

127 f"Categorized error as {category.value}: {pattern}" 

128 ) 

129 return category 

130 

131 return ErrorCategory.UNKNOWN_ERROR 

132 

133 def get_user_friendly_title(self, category: ErrorCategory) -> str: 

134 """ 

135 Get a user-friendly title for an error category 

136 

137 Args: 

138 category: The error category 

139 

140 Returns: 

141 str: User-friendly title 

142 """ 

143 titles = { 

144 ErrorCategory.CONNECTION_ERROR: "Connection Issue", 

145 ErrorCategory.MODEL_ERROR: "LLM Service Error", 

146 ErrorCategory.SEARCH_ERROR: "Search Service Error", 

147 ErrorCategory.SYNTHESIS_ERROR: "Report Generation Error", 

148 ErrorCategory.FILE_ERROR: "File System Error", 

149 ErrorCategory.RATE_LIMIT_ERROR: "API Rate Limit Exceeded", 

150 ErrorCategory.UNKNOWN_ERROR: "Unexpected Error", 

151 } 

152 return titles.get(category, "Error") 

153 

154 def get_suggested_actions(self, category: ErrorCategory) -> list: 

155 """ 

156 Get suggested actions for resolving an error 

157 

158 Args: 

159 category: The error category 

160 

161 Returns: 

162 list: List of suggested actions 

163 """ 

164 suggestions = { 

165 ErrorCategory.CONNECTION_ERROR: [ 

166 "Check if the LLM service (Ollama/LM Studio) is running", 

167 "Verify network connectivity", 

168 "Try switching to a different model provider", 

169 "Check the service logs for more details", 

170 ], 

171 ErrorCategory.MODEL_ERROR: [ 

172 "Verify the model name is correct", 

173 "Check if the model is downloaded and available", 

174 "Validate API keys if using external services", 

175 "Try switching to a different model", 

176 ], 

177 ErrorCategory.SEARCH_ERROR: [ 

178 "Check internet connectivity", 

179 "Try reducing the number of search results", 

180 "Wait a moment and try again", 

181 "Check if search service is configured correctly", 

182 "For local documents: ensure the path is absolute and folder exists", 

183 "Try a different search engine if one is failing", 

184 ], 

185 ErrorCategory.SYNTHESIS_ERROR: [ 

186 "The research data was collected successfully", 

187 "Try switching to a different model for report generation", 

188 "Check the partial results below", 

189 "Review the detailed logs for more information", 

190 ], 

191 ErrorCategory.FILE_ERROR: [ 

192 "Check disk space availability", 

193 "Verify write permissions", 

194 "Try changing the output directory", 

195 "Restart the application", 

196 ], 

197 ErrorCategory.RATE_LIMIT_ERROR: [ 

198 "The API has reached its rate limit", 

199 "Enable LLM Rate Limiting in Settings → Rate Limiting → Enable LLM Rate Limiting", 

200 "Once enabled, the system will automatically learn and adapt to API limits", 

201 "Consider upgrading to a paid API plan for higher limits", 

202 "Try using a different model temporarily", 

203 ], 

204 ErrorCategory.UNKNOWN_ERROR: [ 

205 "Check the detailed logs below for more information", 

206 "Try running the research again", 

207 "Report this issue if it persists", 

208 "Contact support with the error details", 

209 ], 

210 } 

211 return suggestions.get(category, ["Check the logs for more details"]) 

212 

213 def analyze_error( 

214 self, error_message: str, context: Optional[Dict[str, Any]] = None 

215 ) -> Dict[str, Any]: 

216 """ 

217 Perform comprehensive error analysis 

218 

219 Args: 

220 error_message: The error message to analyze 

221 context: Optional context information 

222 

223 Returns: 

224 dict: Comprehensive error analysis 

225 """ 

226 category = self.categorize_error(error_message) 

227 

228 analysis = { 

229 "category": category, 

230 "title": self.get_user_friendly_title(category), 

231 "original_error": error_message, 

232 "suggestions": self.get_suggested_actions(category), 

233 "severity": self._determine_severity(category), 

234 "recoverable": self._is_recoverable(category), 

235 } 

236 

237 # Add context-specific information 

238 if context: 

239 analysis["context"] = context 

240 analysis["has_partial_results"] = bool( 

241 context.get("findings") 

242 or context.get("current_knowledge") 

243 or context.get("search_results") 

244 ) 

245 

246 # Send notifications for specific error types 

247 self._send_error_notifications(category, error_message, context) 

248 

249 return analysis 

250 

251 def _send_error_notifications( 

252 self, 

253 category: ErrorCategory, 

254 error_message: str, 

255 context: Optional[Dict[str, Any]] = None, 

256 ) -> None: 

257 """ 

258 Send notifications for specific error categories. 

259 

260 Args: 

261 category: Error category 

262 error_message: Error message 

263 context: Optional context information 

264 """ 

265 try: 

266 # Only send notifications for AUTH and QUOTA errors 

267 if category not in [ 

268 ErrorCategory.MODEL_ERROR, 

269 ErrorCategory.RATE_LIMIT_ERROR, 

270 ]: 

271 return 

272 

273 # Try to get username from context 

274 username = None 

275 if context: 

276 username = context.get("username") 

277 

278 # Don't send notifications if we can't determine user 

279 if not username: 

280 logger.debug( 

281 "No username in context, skipping error notification" 

282 ) 

283 return 

284 

285 from ..notifications.manager import NotificationManager 

286 from ..notifications import EventType 

287 from ..database.session_context import get_user_db_session 

288 

289 # Get settings snapshot for notification 

290 with get_user_db_session(username) as session: 

291 from ..settings import SettingsManager 

292 

293 settings_manager = SettingsManager(session) 

294 settings_snapshot = settings_manager.get_settings_snapshot() 

295 

296 notification_manager = NotificationManager( 

297 settings_snapshot=settings_snapshot, user_id=username 

298 ) 

299 

300 # Determine event type and build context 

301 if category == ErrorCategory.MODEL_ERROR: 301 ↛ 321line 301 didn't jump to line 321 because the condition on line 301 was always true

302 # Check if it's an auth error specifically 

303 error_str = error_message.lower() 

304 if any( 304 ↛ 319line 304 didn't jump to line 319 because the condition on line 304 was always true

305 pattern in error_str 

306 for pattern in [ 

307 "api key", 

308 "authentication", 

309 "401", 

310 "unauthorized", 

311 ] 

312 ): 

313 event_type = EventType.AUTH_ISSUE 

314 notification_context = { 

315 "service": self._extract_service_name(error_message), 

316 } 

317 else: 

318 # Not an auth error, don't notify 

319 return 

320 

321 elif category == ErrorCategory.RATE_LIMIT_ERROR: 

322 event_type = EventType.API_QUOTA_WARNING 

323 notification_context = { 

324 "service": self._extract_service_name(error_message), 

325 "current": "Unknown", 

326 "limit": "Unknown", 

327 "reset_time": "Unknown", 

328 } 

329 

330 else: 

331 return 

332 

333 # Send notification 

334 notification_manager.send_notification( 

335 event_type=event_type, 

336 context=notification_context, 

337 ) 

338 

339 except Exception as e: 

340 logger.debug(f"Failed to send error notification: {e}") 

341 

342 def _extract_service_name(self, error_message: str) -> str: 

343 """ 

344 Extract service name from error message. 

345 

346 Args: 

347 error_message: Error message 

348 

349 Returns: 

350 Service name or "API Service" 

351 """ 

352 error_lower = error_message.lower() 

353 

354 # Check for common service names 

355 services = [ 

356 "openai", 

357 "anthropic", 

358 "google", 

359 "ollama", 

360 "searxng", 

361 "tavily", 

362 "brave", 

363 ] 

364 

365 for service in services: 

366 if service in error_lower: 

367 return service.title() 

368 

369 return "API Service" 

370 

371 def _determine_severity(self, category: ErrorCategory) -> str: 

372 """Determine error severity level""" 

373 severity_map = { 

374 ErrorCategory.CONNECTION_ERROR: "high", 

375 ErrorCategory.MODEL_ERROR: "high", 

376 ErrorCategory.SEARCH_ERROR: "medium", 

377 ErrorCategory.SYNTHESIS_ERROR: "low", # Can often show partial results 

378 ErrorCategory.FILE_ERROR: "medium", 

379 ErrorCategory.RATE_LIMIT_ERROR: "medium", # Can be resolved with settings 

380 ErrorCategory.UNKNOWN_ERROR: "high", 

381 } 

382 return severity_map.get(category, "medium") 

383 

384 def _is_recoverable(self, category: ErrorCategory) -> bool: 

385 """Determine if error is recoverable with user action""" 

386 recoverable = { 

387 ErrorCategory.CONNECTION_ERROR: True, 

388 ErrorCategory.MODEL_ERROR: True, 

389 ErrorCategory.SEARCH_ERROR: True, 

390 ErrorCategory.SYNTHESIS_ERROR: True, 

391 ErrorCategory.FILE_ERROR: True, 

392 ErrorCategory.RATE_LIMIT_ERROR: True, # Can enable rate limiting 

393 ErrorCategory.UNKNOWN_ERROR: False, 

394 } 

395 return recoverable.get(category, False)