Coverage for src/local_deep_research/error_handling/report_generator.py: 99%
116 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""
2ErrorReportGenerator - Create user-friendly error reports
3"""
5import re
6from typing import Any, Dict, Optional
8from loguru import logger
10from .error_messages import (
11 MAX_ERROR_CLASSIFICATION_CHARS,
12 TYPED_RESEARCH_ERROR_MESSAGES,
13 get_known_research_error_message,
14)
15from .error_reporter import ErrorReporter
18_GENERIC_ERROR_MESSAGE = (
19 "Research failed due to an unexpected error. "
20 "Check the server logs for details."
21)
24class ErrorReportGenerator:
25 """
26 Generates comprehensive, user-friendly error reports
27 """
29 def __init__(self, llm=None):
30 """
31 Initialize error report generator
33 Args:
34 llm: Optional LLM instance (unused, kept for compatibility)
35 """
36 self.error_reporter = ErrorReporter()
38 def generate_error_report(
39 self,
40 error_message: str,
41 query: str,
42 partial_results: Optional[Dict[str, Any]] = None,
43 search_iterations: int = 0,
44 research_id: Optional[str] = None,
45 ) -> str:
46 """
47 Generate a comprehensive error report
49 Args:
50 error_message: The error that occurred
51 query: The research query
52 partial_results: Any partial results that were collected
53 search_iterations: Number of search iterations completed
54 research_id: Research ID for reference
56 Returns:
57 str: Formatted error report in Markdown
58 """
59 try:
60 # Analyze the error
61 context = {
62 "query": query,
63 "search_iterations": search_iterations,
64 "research_id": research_id,
65 "partial_results": partial_results,
66 }
68 if partial_results:
69 context.update(partial_results)
71 error_analysis = self.error_reporter.analyze_error(
72 error_message, context
73 )
75 # Build the simplified report
76 report_parts = []
78 # Header with user-friendly error message and logs reference
79 user_friendly_message = self._make_error_user_friendly(
80 error_message
81 )
82 category_title = error_analysis.get("title", "Error")
84 report_parts.append("# ⚠️ Research Failed")
85 report_parts.append(f"\n**Error Type:** {category_title}")
86 report_parts.append(f"\n**What happened:** {user_friendly_message}")
87 report_parts.append(
88 '\n*For detailed error information, scroll down to the research logs and select "Errors" from the filter.*'
89 )
91 # Support links - moved up for better visibility
92 report_parts.append("\n## 💬 Get Help")
93 report_parts.append("We're here to help you get this working:")
94 report_parts.append(
95 "- 📖 **Documentation & guides:** [Wiki](https://github.com/LearningCircuit/local-deep-research/wiki)"
96 )
97 report_parts.append(
98 "- 💬 **Chat with the community:** [Discord #help-and-support](https://discord.gg/ttcqQeFcJ3)"
99 )
100 report_parts.append(
101 "- 🐛 **Report bugs or get help:** [GitHub Issues](https://github.com/LearningCircuit/local-deep-research/issues) *(don't hesitate to ask if you're stuck!)*"
102 )
103 report_parts.append(
104 "- 💭 **Join discussions:** [Reddit r/LocalDeepResearch](https://www.reddit.com/r/LocalDeepResearch/) *(checked less frequently)*"
105 )
107 # Show partial results if available, as a plain Markdown
108 # section. The report is shown through renderMarkdown()
109 # (services/ui.js), which displays raw HTML in Markdown as
110 # literal text, so a <details> wrapper would show its tags.
111 if error_analysis.get("has_partial_results"):
112 partial_content = self._format_partial_results(partial_results)
113 if partial_content:
114 report_parts.append(
115 f"\n## 📊 Partial Results Available\n\n{partial_content}"
116 )
118 return "\n".join(report_parts)
120 except Exception:
121 # Fallback: always return something, even if error report generation fails
122 logger.exception("Failed to generate error report")
123 return f"""# ⚠️ Research Failed
125**What happened:** {_GENERIC_ERROR_MESSAGE}
127## 💬 Get Help
128We're here to help you get this working:
129- 📖 **Documentation & guides:** [Wiki](https://github.com/LearningCircuit/local-deep-research/wiki)
130- 💬 **Chat with the community:** [Discord #help-and-support](https://discord.gg/ttcqQeFcJ3)
131- 🐛 **Report bugs or get help:** [GitHub Issues](https://github.com/LearningCircuit/local-deep-research/issues) *(don't hesitate to ask if you're stuck!)*
133*Note: Error report generation failed - showing basic error information.*"""
135 def _format_partial_results(
136 self, partial_results: Optional[Dict[str, Any]]
137 ) -> str:
138 """
139 Format partial results for display
141 Args:
142 partial_results: Partial results data
144 Returns:
145 str: Formatted partial results
146 """
147 if not partial_results:
148 return ""
150 formatted_parts = []
152 # Current knowledge summary
153 if "current_knowledge" in partial_results:
154 knowledge = partial_results["current_knowledge"]
155 if (
156 isinstance(knowledge, str)
157 and not knowledge.lstrip().lower().startswith("error:")
158 and len(knowledge.strip()) > 50
159 ):
160 formatted_parts.append("### Research Summary\n")
161 formatted_parts.append(
162 knowledge[:1000] + "..."
163 if len(knowledge) > 1000
164 else knowledge
165 )
166 formatted_parts.append("")
168 # Search results
169 if "search_results" in partial_results:
170 results = partial_results["search_results"]
171 if results: 171 ↛ 182line 171 didn't jump to line 182 because the condition on line 171 was always true
172 formatted_parts.append("### Search Results Found\n")
173 for i, result in enumerate(results[:5], 1): # Show top 5
174 title = result.get("title", "Untitled")
175 url = result.get("url", "")
176 formatted_parts.append(f"{i}. **{title}**")
177 if url:
178 formatted_parts.append(f" - URL: {url}")
179 formatted_parts.append("")
181 # Findings
182 if "findings" in partial_results:
183 findings = partial_results["findings"]
184 if findings: 184 ↛ 206line 184 didn't jump to line 206 because the condition on line 184 was always true
185 valid_findings = [
186 (i, finding)
187 for i, finding in enumerate(findings[:3], 1)
188 if isinstance(finding.get("content"), str)
189 and finding["content"]
190 and not finding["content"]
191 .lstrip()
192 .lower()
193 .startswith("error:")
194 ]
195 if valid_findings:
196 formatted_parts.append("### Research Findings\n")
197 for i, finding in valid_findings:
198 content = finding.get("content", "")
199 phase = finding.get("phase", f"Finding {i}")
200 formatted_parts.append(f"**{phase}:**")
201 formatted_parts.append(
202 content[:500] + "..." if len(content) > 500 else content
203 )
204 formatted_parts.append("")
206 if formatted_parts:
207 formatted_parts.append(
208 "*Note: The above results were successfully collected before the error occurred.*"
209 )
211 return "\n".join(formatted_parts) if formatted_parts else ""
213 def _get_technical_context(
214 self,
215 error_analysis: Dict[str, Any],
216 partial_results: Optional[Dict[str, Any]],
217 ) -> str:
218 """
219 Get additional technical context for the error
221 Args:
222 error_analysis: Error analysis results
223 partial_results: Partial results if available
225 Returns:
226 str: Technical context information
227 """
228 context_parts = []
230 # Add timing information if available
231 if partial_results:
232 if "start_time" in partial_results:
233 context_parts.append(
234 f"- **Start Time:** {partial_results['start_time']}"
235 )
237 if "last_activity" in partial_results:
238 context_parts.append(
239 f"- **Last Activity:** {partial_results['last_activity']}"
240 )
242 # Add model information
243 if "model_config" in partial_results:
244 config = partial_results["model_config"]
245 context_parts.append(
246 f"- **Model:** {config.get('model_name', 'Unknown')}"
247 )
248 context_parts.append(
249 f"- **Provider:** {config.get('provider', 'Unknown')}"
250 )
252 # Add search information
253 if "search_config" in partial_results:
254 search_config = partial_results["search_config"]
255 context_parts.append(
256 f"- **Search Engine:** {search_config.get('engine', 'Unknown')}"
257 )
258 context_parts.append(
259 f"- **Max Results:** {search_config.get('max_results', 'Unknown')}"
260 )
262 # Add any error codes or HTTP status
263 if "status_code" in partial_results:
264 context_parts.append(
265 f"- **Status Code:** {partial_results['status_code']}"
266 )
268 if "error_code" in partial_results:
269 context_parts.append(
270 f"- **Error Code:** {partial_results['error_code']}"
271 )
273 # Add error-specific context based on category
274 category = error_analysis.get("category")
275 if category:
276 if "connection" in category.value.lower():
277 context_parts.append(
278 "- **Network Error:** Connection-related issue detected"
279 )
280 context_parts.append(
281 "- **Retry Recommended:** Check service status and try again"
282 )
283 elif "model" in category.value.lower():
284 context_parts.append(
285 "- **Model Error:** Issue with AI model or configuration"
286 )
287 context_parts.append(
288 "- **Check:** Model service availability and parameters"
289 )
291 return "\n".join(context_parts) if context_parts else ""
293 def generate_quick_error_summary(
294 self, error_message: str
295 ) -> Dict[str, str]:
296 """
297 Generate a quick error summary for API responses
299 Args:
300 error_message: The error message
302 Returns:
303 dict: Quick error summary
304 """
305 error_analysis = self.error_reporter.analyze_error(error_message)
307 return {
308 "title": error_analysis["title"],
309 "category": error_analysis["category"].value,
310 "severity": error_analysis["severity"],
311 "recoverable": error_analysis["recoverable"],
312 }
314 def _make_error_user_friendly(self, error_message: str) -> str:
315 """
316 Replace cryptic technical error messages with user-friendly versions
318 Args:
319 error_message: The original technical error message
321 Returns:
322 str: User-friendly error message without raw exception details
323 """
324 worker_message = get_known_research_error_message(error_message)
325 if worker_message is not None:
326 return worker_message
328 # Bound both typed-token and fallback regex scans. Preserve the exact
329 # worker-message check above: truncation must not turn an untrusted
330 # message with appended details into an authored exact match.
331 error_message = error_message[:MAX_ERROR_CLASSIFICATION_CHARS]
333 # A typed upstream message can still contain a provider URL or a raw
334 # "| Details:" suffix. Select only authored text for its code. Unknown
335 # types fall through to the pattern table for a second classification.
336 typed_error = re.search(
337 r"\(Error type: (\w+)\)", error_message, re.IGNORECASE
338 )
339 if typed_error and typed_error.group(1).lower() != "unknown":
340 return TYPED_RESEARCH_ERROR_MESSAGES.get(
341 typed_error.group(1).lower(), _GENERIC_ERROR_MESSAGE
342 )
343 # Dictionary of technical errors to user-friendly messages
344 error_replacements = {
345 "Egress policy refused this run: a sensitive source": (
346 "Egress policy refused this run: a sensitive source would "
347 "reach an exposing destination."
348 ),
349 "Egress policy refused this run": "Egress policy refused this run.",
350 "max_workers must be greater than 0": (
351 "The LLM failed to generate search questions. This usually means the LLM service isn't responding properly.\n\n"
352 "**Try this:**\n"
353 "- Check if your LLM service (Ollama/LM Studio) is running\n"
354 "- Restart the LLM service\n"
355 "- Try a different model"
356 ),
357 "POST predict.*EOF": (
358 "Lost connection to Ollama. This usually means Ollama stopped responding or there's a network issue.\n\n"
359 "**Try this:**\n"
360 "- Restart Ollama: `ollama serve`\n"
361 "- Check if Ollama is still running: `ps aux | grep ollama`\n"
362 "- Try a different port if 11434 is in use"
363 ),
364 "HTTP error 404.*research results": (
365 "The research completed but the results can't be displayed. The files were likely generated successfully.\n\n"
366 "**Try this:**\n"
367 "- Check the `research_outputs` folder for your report\n"
368 "- Ensure the folder has proper read/write permissions\n"
369 "- Restart the LDR web interface"
370 ),
371 "Connection refused|\\[Errno 111\\]": (
372 "Cannot connect to the LLM service. The service might not be running or is using a different address.\n\n"
373 "**Try this:**\n"
374 "- Start your LLM service (Ollama: `ollama serve`, LM Studio: launch the app)\n"
375 "- **Docker on Mac/Windows:** Change URL from `http://localhost:1234` to `http://host.docker.internal:1234`\n"
376 "- **Docker on Linux:** Use your host IP instead of localhost (find with `hostname -I`)\n"
377 "- Check the service URL in settings matches where your LLM is running\n"
378 "- Verify the port number is correct (Ollama: 11434, LM Studio: 1234)"
379 ),
380 "The search is longer than 256 characters": (
381 "Your search query is too long for GitHub's API (max 256 characters).\n\n"
382 "**Try this:**\n"
383 "- Shorten your research query\n"
384 "- Use a different search engine (SearXNG, Wikipedia, etc.)\n"
385 "- Break your research into smaller, focused queries"
386 ),
387 "No module named.*local_deep_research": (
388 "Installation issue detected. The package isn't properly installed.\n\n"
389 "**Try this:**\n"
390 "- Reinstall: `pip install -e .` from the project directory\n"
391 "- Check you're using the right Python environment\n"
392 "- For Docker users: rebuild the container"
393 ),
394 "Failed to create search engine|search engine.*could not be found": (
395 "Search engine configuration problem.\n\n"
396 "**Try this:**\n"
397 "- Use the default search engine (auto)\n"
398 "- Check search engine settings in Advanced Options\n"
399 "- Ensure required API keys are set for external search engines"
400 ),
401 "No search results found|All search engines.*blocked.*rate.*limited": (
402 "No search results were found for your query. This could mean all search engines are unavailable.\n\n"
403 "**Try this:**\n"
404 "- **If using SearXNG:** Check if your SearXNG Docker container is running: `docker ps`\n"
405 "- **Start SearXNG:** `docker run -d -p 8080:8080 searxng/searxng` then set URL to `http://localhost:8080`\n"
406 "- **Try different search terms:** Use broader, more general keywords\n"
407 "- **Check network connection:** Ensure you can access the internet\n"
408 "- **Switch search engines:** Try SearXNG, Brave, or Google (if API key configured)\n"
409 "- **Check for typos** in your research query"
410 ),
411 "TypeError.*Context.*Size|'<' not supported between instances of .* and 'NoneType'": (
412 "Model configuration issue. The context size setting might not be compatible with your model.\n\n"
413 "**Try this:**\n"
414 "- Check your model's maximum context size\n"
415 "- Leave context size settings at default\n"
416 "- Try a different model"
417 ),
418 "Model.*not found in Ollama": (
419 "The specified model isn't available in Ollama.\n\n"
420 "**Try this:**\n"
421 "- Check available models: `ollama list`\n"
422 "- Pull the model: `ollama pull <model-name>`\n"
423 "- Use the exact model name shown in `ollama list` (e.g., 'gemma2:9b' not 'gemma:latest')"
424 ),
425 "No auth credentials found|401.*API key": (
426 "API key is missing or incorrectly configured.\n\n"
427 "**Try this:**\n"
428 "- Set API key in the web UI settings (LDR reads only the server environment, not a .env file)\n"
429 "- Go to Settings → Advanced → enter your API key\n"
430 "- For custom endpoints, ensure the key format matches what your provider expects"
431 ),
432 "Attempt to write readonly database": (
433 "Permission issue with the database file.\n\n"
434 "**Try this:**\n"
435 "- On Windows: Run as Administrator\n"
436 "- On Linux/Mac: Check folder permissions\n"
437 "- Delete and recreate the database file if corrupted"
438 ),
439 "database is locked|database table is locked": (
440 "The local database is temporarily locked, usually because "
441 "another operation is writing to it at the same time.\n\n"
442 "**Try this:**\n"
443 "- Wait a few seconds and run the research again\n"
444 "- Avoid starting multiple researches that write at the same moment\n"
445 "- If it persists, restart the LDR server to clear stuck connections"
446 ),
447 "does not support tools|tool calling": (
448 "Your current model does not support tool calling, which is required by the **langgraph-agent** strategy.\n\n"
449 "**Try this:**\n"
450 "- **Switch strategy:** Go to Settings → Search Strategy and select **source-based** or **focused-iteration** instead\n"
451 "- **Switch model:** Use a model that supports tool calling (e.g., qwen3, gpt-oss:20b, mistral, llama3.1)\n"
452 "- The langgraph-agent strategy requires models with native tool/function calling support"
453 ),
454 "object has no attribute 'model_dump'": (
455 "The agent received tool calls in a shape LangChain could not parse into structured messages. "
456 "This is a known failure mode of the **langgraph-agent** strategy when the LLM, the OpenAI-compatible server, "
457 "or a proxy in front of either of them produces non-standard tool-call responses.\n\n"
458 "**Try this:**\n"
459 "- **Bypass any proxy/shim** sitting in front of your local LLM server. Tool-call schema translation is the single most common place OpenAI-compatible proxies break, especially when they inject vendor extension fields (e.g. llama.cpp's `timings_per_token`, `return_progress`).\n"
460 "- **Update your serving stack.** llama.cpp has documented OpenAI tool-call format bugs (e.g. arguments emitted as object instead of JSON string). Recent builds of llama.cpp / vLLM / Ollama fix many of these.\n"
461 "- **Use a model with native tool-calling support.** Models without an explicit tool-call template fall back to generic prompting, which frequently produces unparseable outputs.\n"
462 "- **Switch strategy.** If you don't need an agentic loop, Settings → Search Strategy → **source-based** or **focused-iteration** sidesteps tool-calling entirely.\n"
463 "- See [issue #3897](https://github.com/LearningCircuit/local-deep-research/issues/3897) for the original report and discussion."
464 ),
465 "Invalid value.*SearXNG": (
466 "SearXNG configuration or rate limiting issue.\n\n"
467 "**Try this:**\n"
468 "- Keep 'Search snippets only' enabled (don't turn it off)\n"
469 "- Restart SearXNG: `docker restart searxng`\n"
470 "- If rate limited, wait a few minutes or use a VPN"
471 ),
472 "host.*localhost.*Docker|127\\.0\\.0\\.1.*Docker|localhost.*1234.*Docker|LM.*Studio.*Docker.*Mac": (
473 "Docker networking issue - can't connect to services on host.\n\n"
474 "**Try this:**\n"
475 "- **On Mac/Windows Docker:** Replace 'localhost' or '127.0.0.1' with 'host.docker.internal'\n"
476 "- **On Linux Docker:** Use your host's actual IP address (find with `hostname -I`)\n"
477 "- **Example:** Change `http://localhost:1234` to `http://host.docker.internal:1234`\n"
478 "- Ensure the service port isn't blocked by firewall\n"
479 "- Alternative: Use host networking mode (see wiki for setup)"
480 ),
481 "token limit|context length": (
482 "The research exceeded the model's context limit.\n\n"
483 "**Try this:**\n"
484 "- Reduce the research scope or number of search iterations\n"
485 "- Try a model with a larger context window"
486 ),
487 "timeout|timed out": (
488 "The LLM request took too long to complete.\n\n"
489 "**Try this:**\n"
490 "- Retry after checking that your LLM service is running\n"
491 "- Reduce the research scope or increase the request timeout"
492 ),
493 "rate limit": (
494 "The research reached the LLM provider's request limit.\n\n"
495 "**Try this:**\n"
496 "- Wait a few minutes before retrying\n"
497 "- Reduce concurrent research requests or check your provider's quota"
498 ),
499 "connection|network": (
500 "The connection to the LLM service failed.\n\n"
501 "**Try this:**\n"
502 "- Check your network connection and that the LLM service is running\n"
503 "- Verify the service address in settings, then retry"
504 ),
505 "llm error|final answer synthesis fail": (
506 "The language model could not complete the final answer.\n\n"
507 "**Try this:**\n"
508 "- Check that your LLM service is running, then retry\n"
509 "- Try a different model or reduce the research scope"
510 ),
511 }
513 # Check each pattern and replace if found
514 for pattern, replacement in error_replacements.items():
515 if re.search(pattern, error_message, re.IGNORECASE):
516 return replacement
518 return _GENERIC_ERROR_MESSAGE