Coverage for src/local_deep_research/advanced_search_system/candidate_exploration/parallel_explorer.py: 99%

92 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1""" 

2Parallel candidate explorer implementation. 

3 

4This explorer runs multiple search queries in parallel to quickly discover 

5a wide range of candidates. 

6""" 

7 

8import concurrent.futures 

9import time 

10from typing import List, Optional 

11 

12from loguru import logger 

13 

14from ...database.thread_local_session import thread_cleanup 

15from ...utilities.json_utils import get_llm_response_text 

16from ..candidates.base_candidate import Candidate 

17from ..constraints.base_constraint import Constraint 

18from .base_explorer import ( 

19 BaseCandidateExplorer, 

20 ExplorationResult, 

21 ExplorationStrategy, 

22) 

23from ...utilities.llm_utils import invoke_llm_sync 

24 

25 

26class ParallelExplorer(BaseCandidateExplorer): 

27 """ 

28 Parallel candidate explorer that runs multiple searches concurrently. 

29 

30 This explorer: 

31 1. Generates multiple search queries from the initial query 

32 2. Runs searches in parallel for speed 

33 3. Collects and deduplicates candidates 

34 4. Focuses on breadth-first exploration 

35 """ 

36 

37 def __init__( 

38 self, 

39 *args, 

40 max_workers: int = 5, 

41 queries_per_round: int = 8, 

42 max_rounds: int = 3, 

43 **kwargs, 

44 ): 

45 """ 

46 Initialize parallel explorer. 

47 

48 Args: 

49 max_workers: Maximum number of parallel search threads 

50 queries_per_round: Number of queries to generate per round 

51 max_rounds: Maximum exploration rounds 

52 """ 

53 super().__init__(*args, **kwargs) 

54 self.max_workers = max_workers 

55 self.queries_per_round = queries_per_round 

56 self.max_rounds = max_rounds 

57 

58 def explore( 

59 self, 

60 initial_query: str, 

61 constraints: Optional[List[Constraint]] = None, 

62 entity_type: Optional[str] = None, 

63 ) -> ExplorationResult: 

64 """Explore candidates using parallel search strategy.""" 

65 start_time = time.time() 

66 logger.info(f"Starting parallel exploration for: {initial_query}") 

67 

68 all_candidates = [] 

69 exploration_paths = [] 

70 total_searched = 0 

71 

72 # Initial search 

73 current_queries = [initial_query] 

74 

75 with concurrent.futures.ThreadPoolExecutor( 

76 max_workers=self.max_workers 

77 ) as executor: 

78 for round_num in range(self.max_rounds): 

79 if not self._should_continue_exploration( 

80 start_time, len(all_candidates) 

81 ): 

82 break 

83 

84 logger.info( 

85 f"Exploration round {round_num + 1}: {len(current_queries)} queries" 

86 ) 

87 

88 # Submit all queries for parallel execution 

89 future_to_query = { 

90 executor.submit( 

91 thread_cleanup(self._execute_search), query 

92 ): query 

93 for query in current_queries 

94 } 

95 

96 round_candidates = [] 

97 

98 # Collect results as they complete 

99 for future in concurrent.futures.as_completed(future_to_query): 

100 query = future_to_query[future] 

101 total_searched += 1 

102 

103 try: 

104 results = future.result() 

105 candidates = self._extract_candidates_from_results( 

106 results, entity_type 

107 ) 

108 round_candidates.extend(candidates) 

109 exploration_paths.append( 

110 f"Round {round_num + 1}: {query} -> {len(candidates)} candidates" 

111 ) 

112 

113 except Exception: 

114 logger.exception(f"Error processing query '{query}'") 

115 

116 # Add new candidates 

117 all_candidates.extend(round_candidates) 

118 

119 # Generate queries for next round 

120 if round_num < self.max_rounds - 1: 

121 current_queries = self.generate_exploration_queries( 

122 initial_query, all_candidates, constraints 

123 )[: self.queries_per_round] 

124 

125 if not current_queries: 

126 logger.info("No more queries to explore") 

127 break 

128 

129 # Deduplicate and rank 

130 unique_candidates = self._deduplicate_candidates(all_candidates) 

131 ranked_candidates = self._rank_candidates_by_relevance( 

132 unique_candidates, initial_query 

133 ) 

134 

135 # Limit to max candidates 

136 final_candidates = ranked_candidates[: self.max_candidates] 

137 

138 elapsed_time = time.time() - start_time 

139 logger.info( 

140 f"Parallel exploration completed: {len(final_candidates)} unique candidates in {elapsed_time:.1f}s" 

141 ) 

142 

143 return ExplorationResult( 

144 candidates=final_candidates, 

145 total_searched=total_searched, 

146 unique_candidates=len(unique_candidates), 

147 exploration_paths=exploration_paths, 

148 metadata={ 

149 "strategy": "parallel", 

150 "rounds": min(round_num + 1, self.max_rounds), 

151 "max_workers": self.max_workers, 

152 "entity_type": entity_type, 

153 }, 

154 elapsed_time=elapsed_time, 

155 strategy_used=ExplorationStrategy.BREADTH_FIRST, 

156 ) 

157 

158 def generate_exploration_queries( 

159 self, 

160 base_query: str, 

161 found_candidates: List[Candidate], 

162 constraints: Optional[List[Constraint]] = None, 

163 ) -> List[str]: 

164 """Generate queries for parallel exploration.""" 

165 queries = [] 

166 

167 # Query variations based on base query 

168 base_variations = self._generate_query_variations(base_query) 

169 queries.extend(base_variations) 

170 

171 # Queries based on found candidates 

172 if found_candidates: 

173 candidate_queries = self._generate_candidate_based_queries( 

174 found_candidates, base_query 

175 ) 

176 queries.extend(candidate_queries) 

177 

178 # Constraint-based queries 

179 if constraints: 

180 constraint_queries = self._generate_constraint_queries( 

181 constraints, base_query 

182 ) 

183 queries.extend(constraint_queries) 

184 

185 # Remove already explored queries 

186 new_queries = [ 

187 q for q in queries if q.lower() not in self.explored_queries 

188 ] 

189 

190 return new_queries[: self.queries_per_round] 

191 

192 def _generate_query_variations(self, base_query: str) -> List[str]: 

193 """Generate variations of the base query.""" 

194 try: 

195 prompt = f""" 

196Generate 4 search query variations for: "{base_query}" 

197 

198Each variation should: 

1991. Use different keywords but same intent 

2002. Be specific and searchable 

2013. Focus on finding concrete examples or instances 

202 

203Format as numbered list: 

2041. [query] 

2052. [query] 

2063. [query] 

2074. [query] 

208""" 

209 

210 response = get_llm_response_text( 

211 invoke_llm_sync(self.model, prompt) 

212 ).strip() 

213 

214 # Parse numbered list 

215 queries = [] 

216 for line in response.split("\n"): 

217 line = line.strip() 

218 if line and any(line.startswith(f"{i}.") for i in range(1, 10)): 

219 # Remove number prefix 

220 query = line.split(".", 1)[1].strip() 

221 if query: 221 ↛ 216line 221 didn't jump to line 216 because the condition on line 221 was always true

222 queries.append(query) 

223 

224 return queries[:4] 

225 

226 except Exception: 

227 logger.exception("Error generating query variations") 

228 return [] 

229 

230 def _generate_candidate_based_queries( 

231 self, candidates: List[Candidate], base_query: str 

232 ) -> List[str]: 

233 """Generate queries based on found candidates.""" 

234 queries = [] 

235 

236 # Sample a few candidates to avoid too many queries 

237 sample_candidates = candidates[:3] 

238 

239 for candidate in sample_candidates: 

240 # Query for similar entities 

241 queries.append(f'similar to "{candidate.name}"') 

242 queries.append(f'like "{candidate.name}" examples') 

243 

244 return queries 

245 

246 def _generate_constraint_queries( 

247 self, constraints: List[Constraint], base_query: str 

248 ) -> List[str]: 

249 """Generate queries focusing on specific constraints.""" 

250 queries = [] 

251 

252 # Sample constraints to avoid too many queries 

253 for constraint in constraints[:2]: 

254 queries.append(f"{constraint.value} examples") 

255 queries.append(f'"{constraint.value}" instances') 

256 

257 return queries