Coverage for src / local_deep_research / api / settings_utils.py: 76%

139 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-01-11 00:51 +0000

1""" 

2Utilities for managing settings in the programmatic API. 

3 

4This module provides functions to create settings snapshots for the API 

5without requiring database access, reusing the same mechanisms as the 

6web interface. 

7""" 

8 

9import os 

10import copy 

11from typing import Any, Dict, Optional, Union 

12from loguru import logger 

13 

14from ..settings import SettingsManager 

15from ..settings.base import ISettingsManager 

16 

17 

18class InMemorySettingsManager(ISettingsManager): 

19 """ 

20 In-memory settings manager that doesn't require database access. 

21 

22 This is used for the programmatic API to provide settings without 

23 needing a database connection. 

24 """ 

25 

26 # Type mapping from UI elements to Python types (same as SettingsManager) 

27 _UI_ELEMENT_TO_SETTING_TYPE = { 

28 "text": str, 

29 # JSON should already be parsed 

30 "json": lambda x: x, 

31 "password": str, 

32 "select": str, 

33 "number": float, 

34 "range": float, 

35 "checkbox": bool, 

36 } 

37 

38 def __init__(self): 

39 """Initialize with default settings from JSON file.""" 

40 # Create a base manager to get default settings 

41 self._base_manager = SettingsManager(db_session=None) 

42 self._settings = {} 

43 self._load_defaults() 

44 

45 def _get_typed_value(self, setting_data: Dict[str, Any], value: Any) -> Any: 

46 """ 

47 Convert a value to the appropriate type based on the setting's ui_element. 

48 

49 Args: 

50 setting_data: The setting metadata containing ui_element 

51 value: The value to convert 

52 

53 Returns: 

54 The typed value, or the original value if conversion fails 

55 """ 

56 ui_element = setting_data.get("ui_element", "text") 

57 setting_type = self._UI_ELEMENT_TO_SETTING_TYPE.get(ui_element) 

58 

59 if setting_type is None: 59 ↛ 60line 59 didn't jump to line 60 because the condition on line 59 was never true

60 logger.warning( 

61 f"Unknown ui_element type: {ui_element}, returning value as-is" 

62 ) 

63 return value 

64 

65 try: 

66 # Special handling for checkbox/bool with string values 

67 if ui_element == "checkbox" and isinstance(value, str): 

68 return value.lower() in ("true", "1", "yes", "on") 

69 return setting_type(value) 

70 except (ValueError, TypeError) as e: 

71 logger.warning( 

72 f"Failed to convert value {value} to type {setting_type}: {e}" 

73 ) 

74 return value 

75 

76 def _load_defaults(self): 

77 """Load default settings from the JSON file.""" 

78 # Get default settings from the base manager 

79 defaults = self._base_manager.default_settings 

80 

81 # Convert to the format expected by get_all_settings 

82 for key, setting_data in defaults.items(): 

83 self._settings[key] = setting_data.copy() 

84 

85 # Check environment variable override 

86 env_key = f"LDR_{key.upper().replace('.', '_')}" 

87 env_value = os.environ.get(env_key) 

88 if env_value is not None: 

89 # Use the typed value conversion 

90 self._settings[key]["value"] = self._get_typed_value( 

91 setting_data, env_value 

92 ) 

93 

94 # Load search engine configurations from individual JSON files 

95 from importlib import resources 

96 import json 

97 

98 try: 

99 # Load search engines from defaults/settings/search_engines/ 

100 search_engines_dir = resources.files( 

101 "local_deep_research.defaults.settings" 

102 ).joinpath("search_engines") 

103 

104 if search_engines_dir.exists() and search_engines_dir.is_dir(): 104 ↛ exitline 104 didn't return from function '_load_defaults' because the condition on line 104 was always true

105 for json_file in search_engines_dir.glob("*.json"): 

106 try: 

107 engine_settings = json.loads(json_file.read_text()) 

108 # Merge into main settings 

109 for key, setting_data in engine_settings.items(): 

110 if key not in self._settings: 110 ↛ 109line 110 didn't jump to line 109 because the condition on line 110 was always true

111 self._settings[key] = setting_data.copy() 

112 except Exception as e: 

113 logger.warning( 

114 f"Failed to load search engine config from {json_file.name}: {e}" 

115 ) 

116 except Exception as e: 

117 logger.warning(f"Failed to load search engine configs: {e}") 

118 

119 def get_setting( 

120 self, key: str, default: Any = None, check_env: bool = True 

121 ) -> Any: 

122 """Get a setting value.""" 

123 if key in self._settings: 

124 setting_data = self._settings[key] 

125 value = setting_data.get("value", default) 

126 # Ensure the value has the correct type 

127 return self._get_typed_value(setting_data, value) 

128 return default 

129 

130 def set_setting(self, key: str, value: Any, commit: bool = True) -> bool: 

131 """Set a setting value (in memory only).""" 

132 if key in self._settings: 

133 # Validate and convert the value to the correct type 

134 typed_value = self._get_typed_value(self._settings[key], value) 

135 self._settings[key]["value"] = typed_value 

136 return True 

137 return False 

138 

139 def get_all_settings(self) -> Dict[str, Any]: 

140 """Get all settings with metadata.""" 

141 return copy.deepcopy(self._settings) 

142 

143 def load_from_defaults_file( 

144 self, commit: bool = True, **kwargs: Any 

145 ) -> None: 

146 """Reload defaults (already done in __init__).""" 

147 self._load_defaults() 

148 

149 def create_or_update_setting( 

150 self, setting: Union[Dict[str, Any], Any], commit: bool = True 

151 ) -> Optional[Any]: 

152 """Create or update a setting (in memory only).""" 

153 if isinstance(setting, dict) and "key" in setting: 

154 key = setting["key"] 

155 # If the setting has a value, ensure it has the correct type 

156 if "value" in setting: 

157 typed_value = self._get_typed_value(setting, setting["value"]) 

158 setting = setting.copy() # Don't modify the original 

159 setting["value"] = typed_value 

160 self._settings[key] = setting 

161 return setting 

162 return None 

163 

164 def delete_setting(self, key: str, commit: bool = True) -> bool: 

165 """Delete a setting (in memory only).""" 

166 if key in self._settings: 166 ↛ 167line 166 didn't jump to line 167 because the condition on line 166 was never true

167 del self._settings[key] 

168 return True 

169 return False 

170 

171 def import_settings( 

172 self, 

173 settings_data: Dict[str, Any], 

174 commit: bool = True, 

175 overwrite: bool = True, 

176 delete_extra: bool = False, 

177 ) -> None: 

178 """Import settings from a dictionary.""" 

179 if delete_extra: 

180 self._settings.clear() 

181 

182 for key, value in settings_data.items(): 

183 if overwrite or key not in self._settings: 

184 # Ensure proper type handling for imported settings 

185 if isinstance(value, dict) and "value" in value: 

186 typed_value = self._get_typed_value(value, value["value"]) 

187 value = value.copy() 

188 value["value"] = typed_value 

189 self._settings[key] = value 

190 

191 

192def get_default_settings_snapshot() -> Dict[str, Any]: 

193 """ 

194 Get a complete settings snapshot with default values. 

195 

196 This uses the same mechanism as the web interface but without 

197 requiring database access. Environment variables are checked 

198 for overrides. 

199 

200 Returns: 

201 Dict mapping setting keys to their values and metadata 

202 """ 

203 manager = InMemorySettingsManager() 

204 return manager.get_all_settings() 

205 

206 

207def create_settings_snapshot( 

208 overrides: Optional[Dict[str, Any]] = None, 

209 base_settings: Optional[Dict[str, Any]] = None, 

210 **kwargs, 

211) -> Dict[str, Any]: 

212 """ 

213 Create a settings snapshot for the programmatic API. 

214 

215 Args: 

216 overrides: Dict of setting overrides (e.g., {"llm.provider": "openai"}) 

217 This is the most common use case - pass a dict of settings to override. 

218 base_settings: Base settings dict (defaults to get_default_settings_snapshot()) 

219 Rarely needed - only for advanced use cases. 

220 **kwargs: Common setting shortcuts: 

221 - provider: Maps to "llm.provider" 

222 - api_key: Maps to "llm.{provider}.api_key" 

223 - temperature: Maps to "llm.temperature" 

224 - max_search_results: Maps to "search.max_results" 

225 - search_engines: Maps to enabled search engines 

226 

227 Returns: 

228 Complete settings snapshot for use with the API 

229 

230 Examples: 

231 # Most common - pass overrides as first argument 

232 settings = create_settings_snapshot({"search.tool": "wikipedia"}) 

233 

234 # Or use named parameter 

235 settings = create_settings_snapshot(overrides={"llm.provider": "openai"}) 

236 

237 # Use kwargs shortcuts 

238 settings = create_settings_snapshot(provider="openai", temperature=0.7) 

239 

240 # Advanced - provide custom base settings 

241 settings = create_settings_snapshot( 

242 overrides={"search.tool": "wikipedia"}, 

243 base_settings=my_custom_defaults 

244 ) 

245 """ 

246 # Start with base settings or defaults 

247 if base_settings is None: 

248 settings = get_default_settings_snapshot() 

249 else: 

250 settings = copy.deepcopy(base_settings) 

251 

252 # Apply overrides if provided 

253 if overrides: 

254 for key, value in overrides.items(): 

255 if key in settings: 

256 if isinstance(settings[key], dict) and "value" in settings[key]: 256 ↛ 259line 256 didn't jump to line 259 because the condition on line 256 was always true

257 settings[key]["value"] = value 

258 else: 

259 settings[key] = value 

260 else: 

261 # Create a simple setting entry for unknown keys 

262 # Infer ui_element from value type 

263 ui_element = "text" # default 

264 if isinstance(value, bool): 

265 ui_element = "checkbox" 

266 elif isinstance(value, (int, float)): 

267 ui_element = "number" 

268 elif isinstance(value, dict): 

269 ui_element = "json" 

270 

271 settings[key] = {"value": value, "ui_element": ui_element} 

272 

273 # Handle common kwargs shortcuts 

274 if "provider" in kwargs: 

275 provider = kwargs["provider"] 

276 if "llm.provider" in settings: 276 ↛ 279line 276 didn't jump to line 279 because the condition on line 276 was always true

277 settings["llm.provider"]["value"] = provider 

278 else: 

279 settings["llm.provider"] = {"value": provider} 

280 

281 # Handle api_key if provided 

282 if "api_key" in kwargs: 

283 api_key = kwargs["api_key"] 

284 api_key_setting = f"llm.{provider}.api_key" 

285 if api_key_setting in settings: 

286 settings[api_key_setting]["value"] = api_key 

287 else: 

288 settings[api_key_setting] = {"value": api_key} 

289 

290 if "temperature" in kwargs: 

291 if "llm.temperature" in settings: 291 ↛ 294line 291 didn't jump to line 294 because the condition on line 291 was always true

292 settings["llm.temperature"]["value"] = kwargs["temperature"] 

293 else: 

294 settings["llm.temperature"] = {"value": kwargs["temperature"]} 

295 

296 if "max_search_results" in kwargs: 

297 if "search.max_results" in settings: 297 ↛ 302line 297 didn't jump to line 302 because the condition on line 297 was always true

298 settings["search.max_results"]["value"] = kwargs[ 

299 "max_search_results" 

300 ] 

301 else: 

302 settings["search.max_results"] = { 

303 "value": kwargs["max_search_results"] 

304 } 

305 

306 # Add any other common shortcuts here... 

307 

308 return settings 

309 

310 

311def extract_setting_value( 

312 settings_snapshot: Dict[str, Any], key: str, default: Any = None 

313) -> Any: 

314 """ 

315 Extract a setting value from a settings snapshot. 

316 

317 Args: 

318 settings_snapshot: Settings snapshot dict 

319 key: Setting key (e.g., "llm.provider") 

320 default: Default value if not found 

321 

322 Returns: 

323 The setting value 

324 """ 

325 if settings_snapshot is None: 

326 return default 

327 if key in settings_snapshot: 

328 setting = settings_snapshot[key] 

329 if isinstance(setting, dict) and "value" in setting: 

330 return setting["value"] 

331 return setting 

332 return default