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

1from typing import List 

2 

3from langchain_core.language_models import BaseLLM 

4from loguru import logger 

5 

6from .base_question import BaseQuestionGenerator 

7from ...utilities.json_utils import get_llm_response_text 

8from ...utilities.llm_utils import invoke_llm_sync 

9 

10 

11class DecompositionQuestionGenerator(BaseQuestionGenerator): 

12 """Question generator for decomposing complex queries into sub-queries.""" 

13 

14 def __init__(self, model: BaseLLM, max_subqueries: int = 5): 

15 """ 

16 Initialize the question generator. 

17 

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 

24 

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. 

33 

34 Args: 

35 query: The main research query 

36 context: Additional context for question generation 

37 **kwargs: Additional keyword arguments 

38 

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

45 

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 ] 

63 

64 # Remove the question mark 

65 subject_candidate = query[:-1].strip() 

66 

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 

77 

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 

98 

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

103 

104 logger.info( 

105 f"Original query: '{query}', Extracted subject: '{subject}'" 

106 ) 

107 

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. 

112 

113Main Research Topic: {subject} 

114Original Query: {query} 

115 

116Context Information: 

117{context[:2000]} # Limit context length to prevent token limit issues 

118 

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. 

121 

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

128 

129 logger.info( 

130 f"Generating sub-questions for query: '{query}', subject: '{subject}'" 

131 ) 

132 

133 try: 

134 # Get response from LLM 

135 response = invoke_llm_sync(self.model, prompt) 

136 

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) 

140 

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) 

151 

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 

163 

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) :] 

180 

181 if ( 

182 clean_line and len(clean_line) > 10 

183 ): # Ensure it's a meaningful question 

184 sub_queries.append(clean_line) 

185 

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 ) 

191 

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 

208 

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 

231 

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

236 

237 # Simpler prompt 

238 simple_prompt = f"""Break down this research topic into 3 simpler sub-questions: 

239 

240Research Topic: {topic_text} 

241Original Query: {query} 

242 

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. 

245 

246Sub-questions: 

2471. 

2482. 

2493. """ 

250 

251 simple_response = invoke_llm_sync(self.model, simple_prompt) 

252 

253 # Handle different response formats (including list-form 

254 # content blocks) via coercion. 

255 simple_text = get_llm_response_text(simple_response) 

256 

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) 

266 

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

280 

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) 

287 

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 

292 

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) 

297 

298 def _generate_default_questions(self, query: str) -> List[str]: 

299 """ 

300 Generate default questions for a given query when LLM fails. 

301 

302 Args: 

303 query: The main research query 

304 

305 Returns: 

306 List of default questions 

307 """ 

308 # Adjust questions based on the type of query 

309 query = query.strip() 

310 

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 ] 

327 

328 # Extract the subject from a question-format query 

329 subject = query 

330 lower_query = query.lower() 

331 

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

336 

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 

346 

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 

368 

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

373 

374 # For single word or very short subjects, adapt the question format 

375 is_short_subject = len(subject.split()) <= 2 

376 

377 logger.info( 

378 f"Query: '{query}', Identified subject: '{subject}', Short subject: {is_short_subject}" 

379 ) 

380 

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 ] 

443 

444 logger.info( 

445 f"Using {len(default_questions)} default questions: {default_questions}" 

446 ) 

447 return default_questions[: self.max_subqueries]