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