Coverage for src/local_deep_research/advanced_search_system/questions/decomposition_question.py: 93%
129 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
1from typing import List
3from langchain_core.language_models import BaseLLM
4from loguru import logger
6from .base_question import BaseQuestionGenerator
7from ...utilities.json_utils import get_llm_response_text
8from ...utilities.llm_utils import invoke_llm_sync
11class DecompositionQuestionGenerator(BaseQuestionGenerator):
12 """Question generator for decomposing complex queries into sub-queries."""
14 def __init__(self, model: BaseLLM, max_subqueries: int = 5):
15 """
16 Initialize the question generator.
18 Args:
19 model: The language model to use for question generation
20 max_subqueries: Maximum number of sub-queries to generate
21 """
22 super().__init__(model)
23 self.max_subqueries = max_subqueries
25 def generate_questions(
26 self,
27 query: str,
28 context: str,
29 **kwargs,
30 ) -> List[str]:
31 """
32 Generate sub-queries by decomposing the original query.
34 Args:
35 query: The main research query
36 context: Additional context for question generation
37 **kwargs: Additional keyword arguments
39 Returns:
40 List of generated sub-queries
41 """
42 # Extract subject if the query is in question format
43 subject = query
44 lower_query = query.lower()
46 if lower_query.endswith("?"):
47 # Handle question-format queries by extracting the subject
48 question_prefixes = [
49 "what is",
50 "what are",
51 "how does",
52 "how do",
53 "how can",
54 "why is",
55 "why are",
56 "when did",
57 "where is",
58 "which",
59 "who is",
60 "can",
61 "will",
62 ]
64 # Remove the question mark
65 subject_candidate = query[:-1].strip()
67 # Check for common question beginnings and extract the subject
68 for prefix in question_prefixes: 68 ↛ 79line 68 didn't jump to line 79 because the loop on line 68 didn't complete
69 if lower_query.startswith(prefix):
70 # Extract everything after the question prefix
71 subject_candidate = query[len(prefix) :].strip()
72 # Remove trailing ? if present
73 if subject_candidate.endswith("?"): 73 ↛ 75line 73 didn't jump to line 75 because the condition on line 73 was always true
74 subject_candidate = subject_candidate[:-1].strip()
75 subject = subject_candidate
76 break
78 # For compound questions, extract just the primary subject
79 conjunctions = [
80 " and ",
81 " or ",
82 " but ",
83 " as ",
84 " that ",
85 " which ",
86 " when ",
87 " where ",
88 " how ",
89 ]
90 for conjunction in conjunctions:
91 if conjunction in subject.lower():
92 # Take only the part before the conjunction
93 subject = subject.split(conjunction)[0].strip()
94 logger.info(
95 f"Split compound question at '{conjunction}', extracted: '{subject}'"
96 )
97 break
99 # Clean up the subject if it starts with articles
100 for article in ["a ", "an ", "the "]:
101 if subject.lower().startswith(article):
102 subject = subject[len(article) :].strip()
104 logger.info(
105 f"Original query: '{query}', Extracted subject: '{subject}'"
106 )
108 # Create a prompt to decompose the query into sub-questions
109 prompt = f"""Decompose the main research topic into 3-5 specific sub-queries that can be answered independently.
110Focus on breaking down complex concepts and identifying key aspects requiring separate investigation.
111Ensure sub-queries are clear, targeted, and help build a comprehensive understanding.
113Main Research Topic: {subject}
114Original Query: {query}
116Context Information:
117{context[:2000]} # Limit context length to prevent token limit issues
119Your task is to create 3-5 specific questions that will help thoroughly research this topic.
120If the original query is already a question, extract the core subject and formulate questions around that subject.
122Return ONLY the sub-queries, one per line, without numbering or bullet points.
123Example format:
124What is X technology?
125How does X compare to Y?
126What are the security implications of X?
127"""
129 logger.info(
130 f"Generating sub-questions for query: '{query}', subject: '{subject}'"
131 )
133 try:
134 # Get response from LLM
135 response = invoke_llm_sync(self.model, prompt)
137 # Handle different response formats (string or object with content
138 # attribute, including list-form content blocks) via coercion.
139 sub_queries_text = get_llm_response_text(response)
141 # Check for the common "No language models available" error
142 if (
143 "No language models are available" in sub_queries_text
144 or "Please install Ollama" in sub_queries_text
145 ):
146 logger.warning(
147 "LLM returned error about language models not being available, using default questions"
148 )
149 # Create topic-specific default questions based on the query
150 return self._generate_default_questions(query)
152 # Extract sub-queries (one per line)
153 sub_queries = []
154 for line in sub_queries_text.split("\n"):
155 line = line.strip()
156 # Skip empty lines and lines that are just formatting (bullets, numbers)
157 if (
158 not line
159 or line in ["*", "-", "•"]
160 or line.startswith(("- ", "* ", "• ", "1. ", "2. ", "3. "))
161 ):
162 continue
164 # Remove any leading bullets or numbers if they exist
165 clean_line = line
166 for prefix in [
167 "- ",
168 "* ",
169 "• ",
170 "1. ",
171 "2. ",
172 "3. ",
173 "4. ",
174 "5. ",
175 "- ",
176 "#",
177 ]:
178 if clean_line.startswith(prefix): 178 ↛ 179line 178 didn't jump to line 179 because the condition on line 178 was never true
179 clean_line = clean_line[len(prefix) :]
181 if (
182 clean_line and len(clean_line) > 10
183 ): # Ensure it's a meaningful question
184 sub_queries.append(clean_line)
186 # If no sub-queries were extracted, try again with a simpler prompt
187 if not sub_queries:
188 logger.warning(
189 "No sub-queries extracted from first attempt, trying simplified approach"
190 )
192 # Determine if the query is already a question and extract the subject
193 topic_text = query
194 if query.lower().endswith("?"):
195 # Try to extract subject from question
196 for prefix in [ 196 ↛ 210line 196 didn't jump to line 210 because the loop on line 196 didn't complete
197 "what is",
198 "what are",
199 "how does",
200 "how can",
201 "why is",
202 ]:
203 if query.lower().startswith(prefix): 203 ↛ 196line 203 didn't jump to line 196 because the condition on line 203 was always true
204 topic_text = query[len(prefix) :].strip()
205 if topic_text.endswith("?"): 205 ↛ 207line 205 didn't jump to line 207 because the condition on line 205 was always true
206 topic_text = topic_text[:-1].strip()
207 break
209 # For compound topics, extract just the primary subject
210 conjunctions = [
211 " and ",
212 " or ",
213 " but ",
214 " as ",
215 " that ",
216 " which ",
217 " when ",
218 " where ",
219 " how ",
220 ]
221 for conjunction in conjunctions:
222 if conjunction in topic_text.lower(): 222 ↛ 224line 222 didn't jump to line 224 because the condition on line 222 was never true
223 # Take only the part before the conjunction
224 topic_text = topic_text.split(conjunction)[
225 0
226 ].strip()
227 logger.info(
228 f"Simplified prompt: Split compound query at '{conjunction}', extracted: '{topic_text}'"
229 )
230 break
232 # Clean up the topic if it starts with articles
233 for article in ["a ", "an ", "the "]:
234 if topic_text.lower().startswith(article):
235 topic_text = topic_text[len(article) :].strip()
237 # Simpler prompt
238 simple_prompt = f"""Break down this research topic into 3 simpler sub-questions:
240Research Topic: {topic_text}
241Original Query: {query}
243Your task is to create 3 specific questions that will help thoroughly research this topic.
244If the original query is already a question, use the core subject of that question.
246Sub-questions:
2471.
2482.
2493. """
251 simple_response = invoke_llm_sync(self.model, simple_prompt)
253 # Handle different response formats (including list-form
254 # content blocks) via coercion.
255 simple_text = get_llm_response_text(simple_response)
257 # Check again for language model errors
258 if (
259 "No language models are available" in simple_text
260 or "Please install Ollama" in simple_text
261 ):
262 logger.warning(
263 "LLM returned error in simplified prompt, using default questions"
264 )
265 return self._generate_default_questions(query)
267 # Extract sub-queries from the simpler response
268 for line in simple_text.split("\n"):
269 line = line.strip()
270 if (
271 line
272 and not line.startswith("Sub-questions:")
273 and len(line) > 10
274 ):
275 # Clean up numbering
276 for prefix in ["1. ", "2. ", "3. ", "- ", "* "]:
277 if line.startswith(prefix):
278 line = line[len(prefix) :]
279 sub_queries.append(line.strip())
281 # If still no sub-queries, create default ones based on the original query
282 if not sub_queries:
283 logger.warning(
284 "Failed to generate meaningful sub-queries, using default decomposition"
285 )
286 return self._generate_default_questions(query)
288 logger.info(
289 f"Generated {len(sub_queries)} sub-questions: {sub_queries}"
290 )
291 return sub_queries[: self.max_subqueries] # Limit to max_subqueries
293 except Exception:
294 logger.exception("Error generating sub-questions")
295 # Fallback to basic questions in case of error
296 return self._generate_default_questions(query)
298 def _generate_default_questions(self, query: str) -> List[str]:
299 """
300 Generate default questions for a given query when LLM fails.
302 Args:
303 query: The main research query
305 Returns:
306 List of default questions
307 """
308 # Adjust questions based on the type of query
309 query = query.strip()
311 # Check if the query is already in question format
312 question_prefixes = [
313 "what is",
314 "what are",
315 "how does",
316 "how do",
317 "how can",
318 "why is",
319 "why are",
320 "when did",
321 "where is",
322 "which",
323 "who is",
324 "can",
325 "will",
326 ]
328 # Extract the subject from a question-format query
329 subject = query
330 lower_query = query.lower()
332 # Check for common question formats and extract the subject
333 if lower_query.endswith("?"):
334 # Remove the question mark
335 subject = query[:-1].strip()
337 # Check for common question beginnings and extract the subject
338 for prefix in question_prefixes: 338 ↛ 349line 338 didn't jump to line 349 because the loop on line 338 didn't complete
339 if lower_query.startswith(prefix): 339 ↛ 338line 339 didn't jump to line 338 because the condition on line 339 was always true
340 # Extract everything after the question prefix
341 subject = query[len(prefix) :].strip()
342 # Remove trailing ? if present
343 if subject.endswith("?"): 343 ↛ 345line 343 didn't jump to line 345 because the condition on line 343 was always true
344 subject = subject[:-1].strip()
345 break
347 # For compound questions, extract just the primary subject
348 # Look for conjunctions and prepositions that typically separate the subject from the rest
349 conjunctions = [
350 " and ",
351 " or ",
352 " but ",
353 " as ",
354 " that ",
355 " which ",
356 " when ",
357 " where ",
358 " how ",
359 ]
360 for conjunction in conjunctions:
361 if conjunction in subject.lower():
362 # Take only the part before the conjunction
363 subject = subject.split(conjunction)[0].strip()
364 logger.info(
365 f"Split compound question at '{conjunction}', extracted: '{subject}'"
366 )
367 break
369 # Clean up the subject if it starts with articles
370 for article in ["a ", "an ", "the "]:
371 if subject.lower().startswith(article):
372 subject = subject[len(article) :].strip()
374 # For single word or very short subjects, adapt the question format
375 is_short_subject = len(subject.split()) <= 2
377 logger.info(
378 f"Query: '{query}', Identified subject: '{subject}', Short subject: {is_short_subject}"
379 )
381 # Special case for CSRF - if we've extracted just "csrf" from a longer query
382 if (
383 subject.lower() == "csrf"
384 or subject.lower() == "cross-site request forgery"
385 ):
386 # CSRF-specific questions
387 default_questions = [
388 "What is Cross-Site Request Forgery (CSRF)?",
389 "How do CSRF attacks work and what are common attack vectors?",
390 "What are effective CSRF prevention methods and best practices?",
391 "How do CSRF tokens work to prevent attacks?",
392 "What are real-world examples of CSRF vulnerabilities and their impact?",
393 ]
394 elif not subject:
395 # Empty query case
396 default_questions = [
397 "What is the definition of this topic?",
398 "What are the key aspects of this topic?",
399 "What are practical applications of this concept?",
400 ]
401 elif any(
402 term in subject.lower()
403 for term in ["secure", "security", "vulnerability", "attack"]
404 ):
405 # Security-related questions
406 default_questions = [
407 f"What is {subject} and how does it work?",
408 f"What are common {subject} vulnerabilities or attack vectors?",
409 f"What are best practices for preventing {subject} issues?",
410 f"How can {subject} be detected and mitigated?",
411 f"What are real-world examples of {subject} incidents?",
412 ]
413 elif any(
414 term in subject.lower()
415 for term in ["programming", "language", "code", "software"]
416 ):
417 # Programming-related questions
418 default_questions = [
419 f"What is {subject} and how does it work?",
420 f"What are the main features and advantages of {subject}?",
421 f"What are common use cases and applications for {subject}?",
422 f"How does {subject} compare to similar technologies?",
423 f"What are best practices when working with {subject}?",
424 ]
425 elif is_short_subject:
426 # For short subjects (1-2 words), use a dedicated format
427 default_questions = [
428 f"What is {subject}?",
429 f"What are the main characteristics of {subject}?",
430 f"How is {subject} used in practice?",
431 f"What are the advantages and disadvantages of {subject}?",
432 f"How has {subject} evolved over time?",
433 ]
434 else:
435 # Generic questions for any topic
436 default_questions = [
437 f"What is the definition of {subject}?",
438 f"What are the key components or features of {subject}?",
439 f"What are common applications or use cases for {subject}?",
440 f"What are the advantages and limitations of {subject}?",
441 f"How does {subject} compare to alternatives?",
442 ]
444 logger.info(
445 f"Using {len(default_questions)} default questions: {default_questions}"
446 )
447 return default_questions[: self.max_subqueries]