Coverage for src/local_deep_research/web_search_engines/search_engines_config.py: 89%

111 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-20 01:24 +0000

1""" 

2Configuration file for search engines. 

3Loads search engine definitions from the user's configuration. 

4""" 

5 

6from typing import Any, Dict, Optional 

7from sqlalchemy.orm import Session 

8 

9from ..security.secure_logging import logger 

10 

11from ..config.thread_settings import get_setting_from_snapshot 

12from ..utilities.db_utils import get_settings_manager 

13from .search_engine_base import _is_api_key_placeholder 

14 

15 

16def _get_setting( 

17 key: str, 

18 default_value: Any = None, 

19 db_session: Optional[Session] = None, 

20 settings_snapshot: Optional[Dict[str, Any]] = None, 

21 username: Optional[str] = None, 

22) -> Any: 

23 """ 

24 Get a setting from either a database session or settings snapshot. 

25 

26 Args: 

27 key: The setting key 

28 default_value: Default value if setting not found 

29 db_session: Database session for direct access 

30 settings_snapshot: Settings snapshot for thread context 

31 username: Username for backward compatibility 

32 

33 Returns: 

34 The setting value or default_value if not found 

35 """ 

36 # Try settings snapshot first (thread context) 

37 if settings_snapshot: 

38 try: 

39 return get_setting_from_snapshot( 

40 key, default_value, settings_snapshot=settings_snapshot 

41 ) 

42 except Exception as e: 

43 logger.debug(f"Could not get setting {key} from snapshot: {e}") 

44 

45 # Try database session if available 

46 if db_session: 

47 try: 

48 settings_manager = get_settings_manager(db_session, username) 

49 return settings_manager.get_setting(key, default_value) 

50 except Exception as e: 

51 logger.debug(f"Could not get setting {key} from db_session: {e}") 

52 

53 # Return default if all methods fail 

54 logger.warning( 

55 f"Could not retrieve setting '{key}', returning default: {default_value}" 

56 ) 

57 return default_value 

58 

59 

60def _extract_per_engine_config( 

61 raw_config: Dict[str, Any], 

62) -> Dict[str, Dict[str, Any]]: 

63 """ 

64 Converts the "flat" configuration loaded from the settings database into 

65 individual settings dictionaries for each engine. 

66 

67 Args: 

68 raw_config: The raw "flat" configuration. 

69 

70 Returns: 

71 Configuration dictionaries indexed by engine name. 

72 

73 """ 

74 nested_config: dict[str, Any] = {} 

75 for key, value in raw_config.items(): 

76 if "." in key: 

77 # This is a higher-level key. 

78 top_level_key = key.split(".")[0] 

79 lower_keys = ".".join(key.split(".")[1:]) 

80 nested_config.setdefault(top_level_key, {})[lower_keys] = value 

81 else: 

82 # This is a low-level key. 

83 nested_config[key] = value 

84 

85 # Expand all the lower-level keys. 

86 for key, value in nested_config.items(): 

87 if isinstance(value, dict): 

88 # Expand the child keys. 

89 nested_config[key] = _extract_per_engine_config(value) 

90 

91 return nested_config 

92 

93 

94def search_config( 

95 username: Optional[str] = None, 

96 db_session: Optional[Session] = None, 

97 settings_snapshot: Optional[Dict[str, Any]] = None, 

98) -> Dict[str, Any]: 

99 """ 

100 Returns the search engine configuration loaded from the database or settings snapshot. 

101 

102 Args: 

103 username: Username for backward compatibility (deprecated) 

104 db_session: Database session for direct access (preferred for web routes) 

105 settings_snapshot: Settings snapshot for thread context (preferred for background threads) 

106 

107 Returns: 

108 The search engine configuration loaded from the database or snapshot. 

109 """ 

110 # Extract search engine definitions 

111 config_data = _get_setting( 

112 "search.engine.web", 

113 {}, 

114 db_session=db_session, 

115 settings_snapshot=settings_snapshot, 

116 username=username, 

117 ) 

118 

119 search_engines = _extract_per_engine_config(config_data) 

120 

121 # Inject module/class from the hardcoded engine registry. 

122 # This is the single source of truth for which Python module implements 

123 # each engine — these values are never read from the settings DB. 

124 from .engine_registry import ENGINE_REGISTRY 

125 

126 for name, entry in ENGINE_REGISTRY.items(): 

127 if name in search_engines: 

128 search_engines[name]["module_path"] = entry.module_path 

129 search_engines[name]["class_name"] = entry.class_name 

130 if entry.full_search_module: 

131 search_engines[name]["full_search_module"] = ( 

132 entry.full_search_module 

133 ) 

134 search_engines[name]["full_search_class"] = ( 

135 entry.full_search_class 

136 ) 

137 

138 # Add registered retrievers as available search engines 

139 from .retriever_registry import retriever_registry 

140 

141 for name in retriever_registry.list_registered(): 

142 search_engines[name] = { 

143 "module_path": ".engines.search_engine_retriever", 

144 "class_name": "RetrieverSearchEngine", 

145 "requires_api_key": False, 

146 "requires_llm": False, 

147 "description": f"LangChain retriever: {name}", 

148 "strengths": [ 

149 "Domain-specific knowledge", 

150 "No rate limits", 

151 "Fast retrieval", 

152 ], 

153 "weaknesses": ["Limited to indexed content"], 

154 "supports_full_search": True, 

155 "is_retriever": True, # Mark as retriever for identification 

156 } 

157 

158 logger.info( 

159 f"Loaded {len(search_engines)} search engines from configuration file" 

160 ) 

161 logger.info(f"\n {', '.join(sorted(search_engines.keys()))} \n") 

162 

163 # Register Library RAG as a search engine 

164 library_enabled = _get_setting( 

165 "search.engine.library.enabled", 

166 True, 

167 db_session=db_session, 

168 settings_snapshot=settings_snapshot, 

169 username=username, 

170 ) 

171 

172 if library_enabled: 

173 search_engines["library"] = { 

174 "module_path": ".engines.search_engine_library", 

175 "class_name": "LibraryRAGSearchEngine", 

176 "requires_llm": True, 

177 "display_name": "Search All Collections", 

178 "default_params": {}, 

179 "description": "Search across all your document collections using semantic search", 

180 "strengths": [ 

181 "Searches all your curated collections of research papers and documents", 

182 "Uses semantic search for better relevance", 

183 "Returns documents you've already saved and reviewed", 

184 ], 

185 "weaknesses": [ 

186 "Limited to documents already in your collections", 

187 "Requires documents to be indexed first", 

188 ], 

189 "reliability": "High - searches all your collections", 

190 } 

191 logger.info("Registered Library RAG as search engine") 

192 

193 # Register document collections as individual search engines 

194 if library_enabled: 

195 try: 

196 from ..database.models.library import Collection 

197 from ..database.session_context import get_user_db_session 

198 

199 # Get username from settings_snapshot if available 

200 collection_username = ( 

201 settings_snapshot.get("_username") 

202 if settings_snapshot 

203 else username 

204 ) 

205 

206 if collection_username: 

207 with get_user_db_session(collection_username) as session: 

208 collections = session.query(Collection).all() 

209 

210 for collection in collections: 

211 engine_id = f"collection_{collection.id}" 

212 # Add suffix to distinguish from the all-collections search 

213 display_name = f"{collection.name} (Collection)" 

214 # Egress classification follows the per-collection 

215 # public/private flag (default private). A "public" 

216 # collection counts as a public engine (allowed under 

217 # PUBLIC_ONLY); a private one is local-only. NULL 

218 # (pre-migration rows) reads as private — the safe 

219 # default. 

220 collection_is_public = bool( 

221 getattr(collection, "is_public", False) 

222 ) 

223 # Usability flag (NOT egress): whether the LangGraph 

224 # research agent offers this collection as a tool. NULL 

225 # (pre-migration rows) reads as available (True). Uses 

226 # the same `is not False` idiom as the rag_routes 

227 # serializers so all call sites share one NULL→available 

228 # default and can't drift. 

229 collection_agent_enabled = ( 

230 getattr(collection, "agent_enabled", True) 

231 is not False 

232 ) 

233 search_engines[engine_id] = { 

234 "module_path": ".engines.search_engine_collection", 

235 "class_name": "CollectionSearchEngine", 

236 "requires_llm": True, 

237 "is_local": not collection_is_public, 

238 "is_public": collection_is_public, 

239 "agent_enabled": collection_agent_enabled, 

240 "display_name": display_name, 

241 "default_params": { 

242 "collection_id": collection.id, 

243 "collection_name": collection.name, 

244 }, 

245 "description": ( 

246 collection.description 

247 if collection.description 

248 else f"Search documents in {collection.name} collection only" 

249 ), 

250 "strengths": [ 

251 f"Searches only documents in {collection.name}", 

252 "Focused semantic search within specific topic area", 

253 "Returns documents from a curated collection", 

254 ], 

255 "weaknesses": [ 

256 "Limited to documents in this collection", 

257 "Smaller result pool than full library search", 

258 ], 

259 "reliability": "High - searches a specific collection", 

260 } 

261 

262 logger.info( 

263 f"Registered {len(collections)} document collections as search engines" 

264 ) 

265 else: 

266 logger.debug( 

267 "No username available for collection registration" 

268 ) 

269 except Exception: 

270 logger.warning("Could not register document collections") 

271 

272 return search_engines 

273 

274 

275def get_available_engines( 

276 settings_snapshot: Optional[Dict[str, Any]] = None, 

277 use_api_key_services: bool = True, 

278 exclude_engines: Optional[set] = None, 

279) -> Dict[str, Any]: 

280 """ 

281 Return search engines that are actually usable: enabled for auto-search 

282 and with valid API keys when required. 

283 

284 This is the single shared filter used by the langgraph-agent tool 

285 builder so it agrees with the rest of the system on which engines are 

286 available. 

287 

288 Args: 

289 settings_snapshot: Thread-safe settings snapshot. 

290 use_api_key_services: If False, engines that require an API key are 

291 excluded even when the key is present. 

292 exclude_engines: Additional engine names to skip (e.g. the caller's 

293 own name). 

294 

295 Returns: 

296 Dict of engine_name → config for engines that passed all checks. 

297 """ 

298 if not settings_snapshot: 298 ↛ 299line 298 didn't jump to line 299 because the condition on line 298 was never true

299 logger.warning( 

300 "get_available_engines called without settings_snapshot, " 

301 "returning empty dict" 

302 ) 

303 return {} 

304 

305 all_engines = search_config(settings_snapshot=settings_snapshot) 

306 excluded = set(exclude_engines) if exclude_engines else set() 

307 

308 available: Dict[str, Any] = {} 

309 

310 for name, config in all_engines.items(): 

311 if name in excluded: 311 ↛ 312line 311 didn't jump to line 312 because the condition on line 311 was never true

312 continue 

313 

314 # Check use_in_auto_search setting (default False) 

315 auto_search_key = f"search.engine.web.{name}.use_in_auto_search" 

316 use_in_auto = get_setting_from_snapshot( 

317 auto_search_key, False, settings_snapshot=settings_snapshot 

318 ) 

319 if not use_in_auto: 

320 continue 

321 

322 requires_key = config.get("requires_api_key", False) 

323 

324 # Honour the use_api_key_services flag 

325 if requires_key and not use_api_key_services: 325 ↛ 326line 325 didn't jump to line 326 because the condition on line 325 was never true

326 continue 

327 

328 # Validate the API key is actually present 

329 if requires_key: 

330 api_key = _resolve_api_key(name, config, settings_snapshot) 

331 if not api_key: 331 ↛ 337line 331 didn't jump to line 337 because the condition on line 331 was always true

332 logger.debug( 

333 f"Skipping {name} — requires API key but none configured" 

334 ) 

335 continue 

336 

337 available[name] = config 

338 

339 return available 

340 

341 

342def _resolve_api_key( 

343 engine_name: str, 

344 engine_config: Dict[str, Any], 

345 settings_snapshot: Dict[str, Any], 

346) -> Optional[str]: 

347 """ 

348 Try to find a valid API key for *engine_name*. 

349 

350 Resolution order (mirrors ``create_search_engine``): 

351 1. ``search.engine.web.<name>.api_key`` in the snapshot 

352 2. ``api_key`` inside the engine config dict 

353 

354 Returns the key string or None. 

355 """ 

356 api_key = None 

357 api_key_path = f"search.engine.web.{engine_name}.api_key" 

358 

359 api_key_setting = settings_snapshot.get(api_key_path) 

360 if api_key_setting: 360 ↛ 367line 360 didn't jump to line 367 because the condition on line 360 was always true

361 api_key = ( 

362 api_key_setting.get("value") 

363 if isinstance(api_key_setting, dict) 

364 else api_key_setting 

365 ) 

366 

367 if not api_key: 367 ↛ 370line 367 didn't jump to line 370 because the condition on line 367 was always true

368 api_key = engine_config.get("api_key") 

369 

370 if not api_key: 370 ↛ 374line 370 didn't jump to line 374 because the condition on line 370 was always true

371 return None 

372 

373 # Reject common placeholder values 

374 api_key_str = str(api_key).strip() 

375 if _is_api_key_placeholder(api_key_str): 

376 return None 

377 

378 return api_key_str 

379 

380 

381def default_search_engine( 

382 username: Optional[str] = None, 

383 db_session: Optional[Session] = None, 

384 settings_snapshot: Optional[Dict[str, Any]] = None, 

385) -> str: 

386 """ 

387 Returns the configured default search engine. 

388 

389 Args: 

390 username: Username for backward compatibility (deprecated) 

391 db_session: Database session for direct access (preferred for web routes) 

392 settings_snapshot: Settings snapshot for thread context (preferred for background threads) 

393 

394 Returns: 

395 The configured default search engine. 

396 """ 

397 return str( 

398 _get_setting( 

399 "search.engine.DEFAULT_SEARCH_ENGINE", 

400 "wikipedia", 

401 db_session=db_session, 

402 settings_snapshot=settings_snapshot, 

403 username=username, 

404 ) 

405 )