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

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 

8 

9 

10class DecompositionQuestionGenerator(BaseQuestionGenerator): 

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

12 

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

14 """ 

15 Initialize the question generator. 

16 

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 

23 

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. 

32 

33 Args: 

34 query: The main research query 

35 context: Additional context for question generation 

36 **kwargs: Additional keyword arguments 

37 

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

44 

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 ] 

62 

63 # Remove the question mark 

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

65 

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 

76 

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 

97 

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

102 

103 logger.info( 

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

105 ) 

106 

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. 

111 

112Main Research Topic: {subject} 

113Original Query: {query} 

114 

115Context Information: 

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

117 

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. 

120 

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

127 

128 logger.info( 

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

130 ) 

131 

132 try: 

133 # Get response from LLM 

134 response = self.model.invoke(prompt) 

135 

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) 

139 

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) 

150 

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 

162 

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

179 

180 if ( 

181 clean_line and len(clean_line) > 10 

182 ): # Ensure it's a meaningful question 

183 sub_queries.append(clean_line) 

184 

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 ) 

190 

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 

207 

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 

230 

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

235 

236 # Simpler prompt 

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

238 

239Research Topic: {topic_text} 

240Original Query: {query} 

241 

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. 

244 

245Sub-questions: 

2461. 

2472. 

2483. """ 

249 

250 simple_response = self.model.invoke(simple_prompt) 

251 

252 # Handle different response formats (including list-form 

253 # content blocks) via coercion. 

254 simple_text = get_llm_response_text(simple_response) 

255 

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) 

265 

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

279 

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) 

286 

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 

291 

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) 

296 

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

298 """ 

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

300 

301 Args: 

302 query: The main research query 

303 

304 Returns: 

305 List of default questions 

306 """ 

307 # Adjust questions based on the type of query 

308 query = query.strip() 

309 

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 ] 

326 

327 # Extract the subject from a question-format query 

328 subject = query 

329 lower_query = query.lower() 

330 

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

335 

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 

345 

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 

367 

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

372 

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

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

375 

376 logger.info( 

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

378 ) 

379 

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 ] 

442 

443 logger.info( 

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

445 ) 

446 return default_questions[: self.max_subqueries]