Coverage for src / local_deep_research / web / services / settings_manager.py: 58%

289 statements  

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

1import json 

2import os 

3import threading 

4from pathlib import Path 

5from typing import Any, Dict, List, Optional, Type, Union 

6 

7from loguru import logger 

8from sqlalchemy import func, or_ 

9from sqlalchemy.exc import SQLAlchemyError 

10from sqlalchemy.orm import Session 

11 

12from ... import defaults 

13from ...__version__ import __version__ as package_version 

14from ...database.models import Setting, SettingType 

15from ..models.settings import ( 

16 AppSetting, 

17 BaseSetting, 

18 LLMSetting, 

19 ReportSetting, 

20 SearchSetting, 

21) 

22 

23 

24def check_env_setting(key: str) -> str | None: 

25 """ 

26 Checks environment variables for a particular setting. 

27 

28 Args: 

29 key: The database key for the setting. 

30 

31 Returns: 

32 The setting from the environment variables, or None if the variable 

33 is not set. 

34 

35 """ 

36 env_variable_name = f"LDR_{'_'.join(key.split('.')).upper()}" 

37 env_value = os.getenv(env_variable_name) 

38 if env_value is not None: 

39 logger.debug(f"Overriding {key} setting from environment variable.") 

40 return env_value 

41 

42 

43class SettingsManager: 

44 """ 

45 Manager for handling application settings with database storage and file fallback. 

46 Provides methods to get and set settings, with the ability to override settings in memory. 

47 """ 

48 

49 _UI_ELEMENT_TO_SETTING_TYPE = { 

50 "text": str, 

51 # SQLAlchemy should already handle JSON parsing. 

52 "json": lambda x: x, 

53 "password": str, 

54 "select": str, 

55 "number": float, 

56 "range": float, 

57 "checkbox": bool, 

58 } 

59 

60 def __init__(self, db_session: Optional[Session] = None): 

61 """ 

62 Initialize the settings manager 

63 

64 Args: 

65 db_session: SQLAlchemy session for database operations 

66 """ 

67 self.db_session = db_session 

68 self.db_first = True # Always prioritize DB settings 

69 

70 # Store the thread ID this instance was created in 

71 self._creation_thread_id = threading.get_ident() 

72 

73 # Initialize settings lock as None - will be checked lazily 

74 self.__settings_locked = None 

75 

76 def _check_thread_safety(self): 

77 """Check if this instance is being used in the same thread it was created in.""" 

78 current_thread_id = threading.get_ident() 

79 if self.db_session and current_thread_id != self._creation_thread_id: 79 ↛ 80line 79 didn't jump to line 80 because the condition on line 79 was never true

80 raise RuntimeError( 

81 f"SettingsManager instance created in thread {self._creation_thread_id} " 

82 f"is being used in thread {current_thread_id}. This is not thread-safe! " 

83 f"Create a new SettingsManager instance within the current thread context." 

84 ) 

85 

86 @property 

87 def settings_locked(self) -> bool: 

88 """Check if settings are locked (lazy evaluation).""" 

89 if self.__settings_locked is None: 

90 try: 

91 self.__settings_locked = self.get_setting( 

92 "app.lock_settings", False 

93 ) 

94 if self.settings_locked: 94 ↛ 95line 94 didn't jump to line 95 because the condition on line 94 was never true

95 logger.info( 

96 "Settings are locked. Disabling all settings changes." 

97 ) 

98 except Exception: 

99 # If we can't check, assume not locked 

100 self.__settings_locked = False 

101 return self.__settings_locked 

102 

103 @property 

104 def default_settings(self) -> Dict[str, Any]: 

105 """ 

106 Returns: 

107 The default settings, loaded from JSON files and merged. 

108 Automatically discovers and loads all .json files in the defaults directory 

109 and its subdirectories. 

110 

111 """ 

112 settings = {} 

113 

114 try: 

115 # Get the defaults package path 

116 defaults_path = Path(defaults.__file__).parent 

117 

118 # Find all JSON files recursively in the defaults directory 

119 json_files = list(defaults_path.rglob("*.json")) 

120 

121 # Sort for consistent loading order 

122 json_files.sort() 

123 

124 logger.info(f"Found {len(json_files)} JSON settings files") 

125 

126 # Load and merge all JSON files 

127 for json_file in json_files: 

128 try: 

129 with open(json_file, "r") as f: 

130 file_settings = json.load(f) 

131 

132 # Get relative path for logging 

133 relative_path = json_file.relative_to(defaults_path) 

134 

135 # Warn about key conflicts 

136 conflicts = set(settings.keys()) & set(file_settings.keys()) 

137 if conflicts: 

138 logger.warning( 

139 f"Keys {conflicts} from {relative_path} override existing values" 

140 ) 

141 

142 settings.update(file_settings) 

143 logger.debug(f"Loaded {relative_path}") 

144 

145 except json.JSONDecodeError: 

146 logger.exception(f"Invalid JSON in {json_file}") 

147 except Exception as e: 

148 logger.warning(f"Could not load {json_file}: {e}") 

149 

150 except Exception as e: 

151 logger.warning(f"Error loading settings files: {e}") 

152 

153 logger.info(f"Loaded {len(settings)} total settings") 

154 return settings 

155 

156 def __get_typed_setting_value( 

157 self, 

158 setting: Type[Setting], 

159 default: Any = None, 

160 check_env: bool = True, 

161 ) -> Any: 

162 """ 

163 Extracts the value for a particular setting, ensuring that it has the 

164 correct type. 

165 

166 Args: 

167 setting: The setting to get the value for. 

168 default: Default value to return if the value of the setting is 

169 invalid. 

170 check_env: If true, it will check the environment variable for 

171 this setting before reading from the DB. 

172 

173 Returns: 

174 The value of the setting. 

175 

176 """ 

177 setting_type = self._UI_ELEMENT_TO_SETTING_TYPE.get( 

178 setting.ui_element, None 

179 ) 

180 if setting_type is None: 

181 logger.warning( 

182 "Got unknown type {} for setting {}, returning default value.", 

183 setting.ui_element, 

184 setting.key, 

185 ) 

186 return default 

187 

188 # Check environment variable first, then database. 

189 if check_env: 189 ↛ 202line 189 didn't jump to line 202 because the condition on line 189 was always true

190 env_value = check_env_setting(setting.key) 

191 if env_value is not None: 

192 try: 

193 return setting_type(env_value) 

194 except ValueError: 

195 logger.warning( 

196 "Setting {} has invalid value {}. Falling back to DB.", 

197 setting.key, 

198 env_value, 

199 ) 

200 

201 # If environment variable does not exist, read from the database. 

202 try: 

203 return setting_type(setting.value) 

204 except ValueError: 

205 logger.warning( 

206 "Setting {} has invalid value {}. Returning default.", 

207 setting.key, 

208 setting.value, 

209 ) 

210 return default 

211 

212 def __query_settings(self, key: str | None = None) -> List[Type[Setting]]: 

213 """ 

214 Abstraction for querying settings that also transparently handles 

215 reading the default settings file if the DB is not enabled. 

216 

217 Args: 

218 key: The key to read. If None, it will read everything. 

219 

220 Returns: 

221 The settings it queried. 

222 

223 """ 

224 if self.db_session: 224 ↛ 238line 224 didn't jump to line 238 because the condition on line 224 was always true

225 self._check_thread_safety() 

226 query = self.db_session.query(Setting) 

227 if key is not None: 

228 # This will find exact matches and any subkeys. 

229 query = query.filter( 

230 or_( 

231 Setting.key == key, 

232 Setting.key.startswith(f"{key}."), 

233 ) 

234 ) 

235 return query.all() 

236 

237 else: 

238 logger.debug( 

239 "DB is disabled, reading setting '{}' from defaults file.", key 

240 ) 

241 

242 settings = [] 

243 for candidate_key, setting in self.default_settings.items(): 

244 if key is None or ( 

245 candidate_key == key or candidate_key.startswith(f"{key}.") 

246 ): 

247 settings.append(Setting(key=candidate_key, **setting)) 

248 

249 return settings 

250 

251 def get_setting( 

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

253 ) -> Any: 

254 """ 

255 Get a setting value 

256 

257 Args: 

258 key: Setting key 

259 default: Default value if setting is not found 

260 check_env: If true, it will check the environment variable for 

261 this setting before reading from the DB. 

262 

263 Returns: 

264 Setting value or default if not found 

265 """ 

266 # If using database first approach and session available, check database 

267 try: 

268 settings = self.__query_settings(key) 

269 if len(settings) == 1: 

270 # This is a bottom-level key. 

271 return self.__get_typed_setting_value( 

272 settings[0], default, check_env 

273 ) 

274 elif len(settings) > 1: 

275 # This is a higher-level key. 

276 settings_map = {} 

277 for setting in settings: 

278 output_key = setting.key.removeprefix(f"{key}.") 

279 settings_map[output_key] = self.__get_typed_setting_value( 

280 setting, default, check_env 

281 ) 

282 return settings_map 

283 except SQLAlchemyError as e: 

284 logger.exception( 

285 f"Error retrieving setting {key} from database: {e}" 

286 ) 

287 

288 # Return default if not found 

289 return default 

290 

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

292 """ 

293 Set a setting value 

294 

295 Args: 

296 key: Setting key 

297 value: Setting value 

298 commit: Whether to commit the change 

299 

300 Returns: 

301 True if successful, False otherwise 

302 """ 

303 if not self.db_session: 303 ↛ 304line 303 didn't jump to line 304 because the condition on line 303 was never true

304 logger.error( 

305 "Cannot edit setting {} because no DB was provided.", key 

306 ) 

307 return False 

308 if self.settings_locked: 308 ↛ 309line 308 didn't jump to line 309 because the condition on line 308 was never true

309 logger.error("Cannot edit setting {} because they are locked.", key) 

310 return False 

311 

312 # Always update database if available 

313 try: 

314 self._check_thread_safety() 

315 setting = ( 

316 self.db_session.query(Setting) 

317 .filter(Setting.key == key) 

318 .first() 

319 ) 

320 if setting: 

321 if not setting.editable: 321 ↛ 322line 321 didn't jump to line 322 because the condition on line 321 was never true

322 logger.error( 

323 "Cannot change setting '{}' because it " 

324 "is marked as non-editable.", 

325 key, 

326 ) 

327 return False 

328 

329 setting.value = value 

330 setting.updated_at = ( 

331 func.now() 

332 ) # Explicitly set the current timestamp 

333 else: 

334 # Determine setting type from key 

335 setting_type = SettingType.APP 

336 if key.startswith("llm."): 336 ↛ 337line 336 didn't jump to line 337 because the condition on line 336 was never true

337 setting_type = SettingType.LLM 

338 elif key.startswith("search."): 338 ↛ 339line 338 didn't jump to line 339 because the condition on line 338 was never true

339 setting_type = SettingType.SEARCH 

340 elif key.startswith("report."): 340 ↛ 341line 340 didn't jump to line 341 because the condition on line 340 was never true

341 setting_type = SettingType.REPORT 

342 

343 # Create a new setting 

344 new_setting = Setting( 

345 key=key, 

346 value=value, 

347 type=setting_type, 

348 name=key.split(".")[-1].replace("_", " ").title(), 

349 ui_element="text", 

350 description=f"Setting for {key}", 

351 ) 

352 self.db_session.add(new_setting) 

353 

354 if commit: 354 ↛ 359line 354 didn't jump to line 359 because the condition on line 354 was always true

355 self.db_session.commit() 

356 # Emit WebSocket event for settings change 

357 self._emit_settings_changed([key]) 

358 

359 return True 

360 except SQLAlchemyError: 

361 logger.exception("Error setting value") 

362 self.db_session.rollback() 

363 return False 

364 

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

366 """ 

367 Get all settings 

368 

369 Returns: 

370 Dictionary of all settings 

371 """ 

372 result = {} 

373 

374 # Add database settings if available 

375 try: 

376 for setting in self.__query_settings(): 

377 result[setting.key] = dict( 

378 value=setting.value, 

379 type=setting.type.name, 

380 name=setting.name, 

381 description=setting.description, 

382 category=setting.category, 

383 ui_element=setting.ui_element, 

384 options=setting.options, 

385 min_value=setting.min_value, 

386 max_value=setting.max_value, 

387 step=setting.step, 

388 visible=setting.visible, 

389 editable=False 

390 if self.settings_locked 

391 else setting.editable, 

392 ) 

393 

394 # Override from the environment variables if needed. 

395 env_value = check_env_setting(setting.key) 

396 if env_value is not None: 396 ↛ 397line 396 didn't jump to line 397 because the condition on line 396 was never true

397 result[setting.key]["value"] = env_value 

398 # Mark it as non-editable, because changes to the DB 

399 # value have no effect as long as the environment 

400 # variable is set. 

401 result[setting.key]["editable"] = False 

402 except SQLAlchemyError as e: 

403 logger.exception( 

404 f"Error retrieving all settings from database: {e}" 

405 ) 

406 

407 return result 

408 

409 def create_or_update_setting( 

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

411 ) -> Optional[Setting]: 

412 """ 

413 Create or update a setting 

414 

415 Args: 

416 setting: Setting object or dictionary 

417 commit: Whether to commit the change 

418 

419 Returns: 

420 The created or updated Setting model, or None if failed 

421 """ 

422 if not self.db_session: 

423 logger.warning( 

424 "No database session available, cannot create/update setting" 

425 ) 

426 return None 

427 if self.settings_locked: 

428 logger.error("Cannot edit settings because they are locked.") 

429 return None 

430 

431 # Convert dict to BaseSetting if needed 

432 if isinstance(setting, dict): 

433 # Determine type from key if not specified 

434 if "type" not in setting and "key" in setting: 

435 key = setting["key"] 

436 if key.startswith("llm."): 

437 setting_obj = LLMSetting(**setting) 

438 elif key.startswith("search."): 

439 setting_obj = SearchSetting(**setting) 

440 elif key.startswith("report."): 

441 setting_obj = ReportSetting(**setting) 

442 else: 

443 setting_obj = AppSetting(**setting) 

444 else: 

445 # Use generic BaseSetting 

446 setting_obj = BaseSetting(**setting) 

447 else: 

448 setting_obj = setting 

449 

450 try: 

451 # Check if setting exists 

452 db_setting = ( 

453 self.db_session.query(Setting) 

454 .filter(Setting.key == setting_obj.key) 

455 .first() 

456 ) 

457 

458 if db_setting: 

459 # Update existing setting 

460 if not db_setting.editable: 

461 logger.error( 

462 "Cannot change setting '{}' because it " 

463 "is marked as non-editable.", 

464 setting_obj.key, 

465 ) 

466 return None 

467 

468 db_setting.value = setting_obj.value 

469 db_setting.name = setting_obj.name 

470 db_setting.description = setting_obj.description 

471 db_setting.category = setting_obj.category 

472 db_setting.ui_element = setting_obj.ui_element 

473 db_setting.options = setting_obj.options 

474 db_setting.min_value = setting_obj.min_value 

475 db_setting.max_value = setting_obj.max_value 

476 db_setting.step = setting_obj.step 

477 db_setting.visible = setting_obj.visible 

478 db_setting.editable = setting_obj.editable 

479 db_setting.updated_at = ( 

480 func.now() 

481 ) # Explicitly set the current timestamp 

482 else: 

483 # Create new setting 

484 db_setting = Setting( 

485 key=setting_obj.key, 

486 value=setting_obj.value, 

487 type=setting_obj.type, # It's already a SettingType enum 

488 name=setting_obj.name, 

489 description=setting_obj.description, 

490 category=setting_obj.category, 

491 ui_element=setting_obj.ui_element, 

492 options=setting_obj.options, 

493 min_value=setting_obj.min_value, 

494 max_value=setting_obj.max_value, 

495 step=setting_obj.step, 

496 visible=setting_obj.visible, 

497 editable=setting_obj.editable, 

498 ) 

499 self.db_session.add(db_setting) 

500 

501 if commit: 

502 self.db_session.commit() 

503 # Emit WebSocket event for settings change 

504 self._emit_settings_changed([setting_obj.key]) 

505 

506 return db_setting 

507 

508 except SQLAlchemyError as e: 

509 logger.exception( 

510 f"Error creating/updating setting {setting_obj.key}: {e}" 

511 ) 

512 self.db_session.rollback() 

513 return None 

514 

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

516 """ 

517 Delete a setting 

518 

519 Args: 

520 key: Setting key 

521 commit: Whether to commit the change 

522 

523 Returns: 

524 True if successful, False otherwise 

525 """ 

526 if not self.db_session: 526 ↛ 527line 526 didn't jump to line 527 because the condition on line 526 was never true

527 logger.warning( 

528 "No database session available, cannot delete setting" 

529 ) 

530 return False 

531 

532 try: 

533 # Remove from database 

534 result = ( 

535 self.db_session.query(Setting) 

536 .filter(Setting.key == key) 

537 .delete() 

538 ) 

539 

540 if commit: 540 ↛ 541line 540 didn't jump to line 541 because the condition on line 540 was never true

541 self.db_session.commit() 

542 

543 return result > 0 

544 except SQLAlchemyError: 

545 logger.exception("Error deleting setting") 

546 self.db_session.rollback() 

547 return False 

548 

549 def load_from_defaults_file( 

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

551 ) -> None: 

552 """ 

553 Import settings from the defaults settings file. 

554 

555 Args: 

556 commit: Whether to commit changes to database 

557 **kwargs: Will be passed to `import_settings`. 

558 

559 """ 

560 self.import_settings(self.default_settings, commit=commit, **kwargs) 

561 

562 def db_version_matches_package(self) -> bool: 

563 """ 

564 Returns: 

565 True if the version saved in the DB matches the package version. 

566 

567 """ 

568 db_version = self.get_setting("app.version") 

569 logger.debug( 

570 f"App version saved in DB is {db_version}, have package " 

571 f"settings from version {package_version}." 

572 ) 

573 

574 return db_version == package_version 

575 

576 def update_db_version(self) -> None: 

577 """ 

578 Updates the version saved in the DB based on the package version. 

579 

580 """ 

581 logger.debug(f"Updating saved DB version to {package_version}.") 

582 

583 self.delete_setting("app.version", commit=False) 

584 version = Setting( 

585 key="app.version", 

586 value=package_version, 

587 description="Version of the app this database is associated with.", 

588 editable=False, 

589 name="App Version", 

590 type=SettingType.APP, 

591 ui_element="text", 

592 visible=False, 

593 ) 

594 

595 self.db_session.add(version) 

596 self.db_session.commit() 

597 

598 def import_settings( 

599 self, 

600 settings_data: Dict[str, Any], 

601 commit: bool = True, 

602 overwrite: bool = True, 

603 delete_extra: bool = False, 

604 ) -> None: 

605 """ 

606 Import settings directly from the export format. This can be used to 

607 re-import settings that have been exported with `get_all_settings()`. 

608 

609 Args: 

610 settings_data: The raw settings data to import. 

611 commit: Whether to commit the DB after loading the settings. 

612 overwrite: If true, it will overwrite the value of settings that 

613 are already in the database. 

614 delete_extra: If true, it will delete any settings that are in 

615 the database but don't have a corresponding entry in 

616 `settings_data`. 

617 

618 """ 

619 logger.debug(f"Importing {len(settings_data)} settings") 

620 

621 for key, setting_values in settings_data.items(): 

622 if not overwrite: 

623 existing_value = self.get_setting(key) 

624 if existing_value is not None: 624 ↛ 626line 624 didn't jump to line 626 because the condition on line 624 was never true

625 # Preserve the value from this setting. 

626 setting_values["value"] = existing_value 

627 

628 # Delete any existing setting so we can completely overwrite it. 

629 self.delete_setting(key, commit=False) 

630 

631 setting = Setting(key=key, **setting_values) 

632 self.db_session.add(setting) 

633 

634 if commit or delete_extra: 634 ↛ 640line 634 didn't jump to line 640 because the condition on line 634 was always true

635 self.db_session.commit() 

636 logger.info(f"Successfully imported {len(settings_data)} settings") 

637 # Emit WebSocket event for all imported settings 

638 self._emit_settings_changed(list(settings_data.keys())) 

639 

640 if delete_extra: 

641 all_settings = self.get_all_settings() 

642 for key in all_settings: 

643 if key not in settings_data: 643 ↛ 644line 643 didn't jump to line 644 because the condition on line 643 was never true

644 logger.debug(f"Deleting extraneous setting: {key}") 

645 self.delete_setting(key, commit=False) 

646 

647 def _create_setting(self, key, value, setting_type): 

648 """Create a setting with appropriate metadata""" 

649 

650 # Determine appropriate category 

651 category = None 

652 ui_element = "text" 

653 

654 # Determine category based on key pattern 

655 if key.startswith("app."): 

656 category = "app_interface" 

657 elif key.startswith("llm."): 

658 if any( 

659 param in key 

660 for param in [ 

661 "temperature", 

662 "max_tokens", 

663 "n_batch", 

664 "n_gpu_layers", 

665 ] 

666 ): 

667 category = "llm_parameters" 

668 else: 

669 category = "llm_general" 

670 elif key.startswith("search."): 

671 if any( 

672 param in key 

673 for param in ["iterations", "questions", "results", "region"] 

674 ): 

675 category = "search_parameters" 

676 else: 

677 category = "search_general" 

678 elif key.startswith("report."): 

679 category = "report_parameters" 

680 

681 # Determine UI element type based on value 

682 if isinstance(value, bool): 

683 ui_element = "checkbox" 

684 elif isinstance(value, (int, float)) and not isinstance(value, bool): 

685 ui_element = "number" 

686 elif isinstance(value, (dict, list)): 

687 ui_element = "textarea" 

688 

689 # Build setting object 

690 setting_dict = { 

691 "key": key, 

692 "value": value, 

693 "type": setting_type.value.lower(), 

694 "name": key.split(".")[-1].replace("_", " ").title(), 

695 "description": f"Setting for {key}", 

696 "category": category, 

697 "ui_element": ui_element, 

698 } 

699 

700 # Create the setting in the database 

701 self.create_or_update_setting(setting_dict, commit=False) 

702 

703 def _emit_settings_changed(self, changed_keys: list = None): 

704 """ 

705 Emit WebSocket event when settings change 

706 

707 Args: 

708 changed_keys: List of setting keys that changed 

709 """ 

710 try: 

711 # Import here to avoid circular imports 

712 from .socket_service import SocketIOService 

713 

714 try: 

715 socket_service = SocketIOService() 

716 except ValueError: 

717 logger.debug( 

718 "Not emitting socket event because server is not initialized." 

719 ) 

720 return 

721 

722 # Get the changed settings 

723 settings_data = {} 

724 if changed_keys: 724 ↛ 731line 724 didn't jump to line 731 because the condition on line 724 was always true

725 for key in changed_keys: 

726 setting_value = self.get_setting(key) 

727 if setting_value is not None: 

728 settings_data[key] = {"value": setting_value} 

729 

730 # Emit the settings change event 

731 from datetime import datetime, UTC 

732 

733 socket_service.emit_socket_event( 

734 "settings_changed", 

735 { 

736 "changed_keys": changed_keys or [], 

737 "settings": settings_data, 

738 "timestamp": datetime.now(UTC).isoformat(), 

739 }, 

740 ) 

741 

742 logger.debug( 

743 f"Emitted settings_changed event for keys: {changed_keys}" 

744 ) 

745 

746 except Exception: 

747 logger.exception("Failed to emit settings change event") 

748 # Don't let WebSocket emission failures break settings saving