Coverage for src/local_deep_research/web/warning_checks/__init__.py: 89%

151 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +0000

1"""Warning calculation for the settings UI. 

2 

3Thin orchestrator: reads settings from a single DB session, 

4delegates to pure check functions in hardware.py and context.py. 

5""" 

6 

7from typing import List, Optional 

8 

9from loguru import logger 

10 

11from ...database.session_context import get_user_db_session 

12from ...utilities.db_utils import get_settings_manager 

13from .context import ( 

14 check_context_below_history, 

15 check_context_truncation_history, 

16) 

17from .backup import ( 

18 check_backup_disabled, 

19 check_backup_healthy, 

20 check_no_backups_exist, 

21) 

22from .hardware import ( 

23 LOCAL_PROVIDERS, 

24 check_high_context, 

25 check_legacy_server_config, 

26 check_model_mismatch, 

27) 

28from ...security.egress.policy import DEFAULT_EGRESS_SCOPE 

29from ...security.egress.warnings import ( 

30 check_cloud_embeddings_enabled, 

31 check_cloud_llm_enabled, 

32 check_effective_scope, 

33 check_local_engine_public_url, 

34 check_private_engine_url_blocked, 

35 check_public_egress_enabled, 

36 check_trusted_destinations, 

37 check_unprotected_egress, 

38) 

39from ...constants import DEFAULT_SEARCH_TOOL 

40 

41 

42def _safe_check(check_fn, *args, **kwargs): 

43 """Run a single warning check, returning None on failure.""" 

44 try: 

45 return check_fn(*args, **kwargs) 

46 except Exception: 

47 name = getattr(check_fn, "__name__", repr(check_fn)) 

48 logger.exception(f"Warning check {name} failed") 

49 return None 

50 

51 

52def calculate_warnings(username: Optional[str] = None) -> List[dict]: 

53 """Calculate current warning conditions based on settings. 

54 

55 Uses a single DB session for all setting reads and history queries. 

56 

57 Args: 

58 username: User whose settings/history to inspect. Callers in 

59 FastAPI routes should pass this explicitly (from Depends( 

60 require_auth)). When omitted, returns [] since we no longer 

61 have a Flask session to read it from. 

62 """ 

63 warnings: List[dict] = [] 

64 

65 try: 

66 if not username: 

67 return [] 

68 with get_user_db_session(username) as db_session: 

69 if not db_session: 

70 return [] 

71 

72 settings_manager = get_settings_manager(db_session, username) 

73 

74 # Read all needed settings in one session 

75 provider = settings_manager.get_setting( 

76 "llm.provider", "ollama" 

77 ).lower() 

78 local_context = settings_manager.get_setting( 

79 "llm.local_context_window_size", 8192 

80 ) 

81 current_model = settings_manager.get_setting("llm.model", "") 

82 dismiss_high_context = settings_manager.get_setting( 

83 "app.warnings.dismiss_high_context", False 

84 ) 

85 dismiss_model_mismatch = settings_manager.get_setting( 

86 "app.warnings.dismiss_model_mismatch", False 

87 ) 

88 dismiss_context_below_history = settings_manager.get_setting( 

89 "app.warnings.dismiss_context_below_history", False 

90 ) 

91 dismiss_context_truncation_history = settings_manager.get_setting( 

92 "app.warnings.dismiss_context_truncation_history", False 

93 ) 

94 dismiss_legacy_config = settings_manager.get_setting( 

95 "app.warnings.dismiss_legacy_config", False 

96 ) 

97 backup_enabled = settings_manager.get_setting( 

98 "backup.enabled", True 

99 ) 

100 dismiss_backup_disabled = settings_manager.get_setting( 

101 "app.warnings.dismiss_backup_disabled", False 

102 ) 

103 dismiss_no_backups = settings_manager.get_setting( 

104 "app.warnings.dismiss_no_backups", False 

105 ) 

106 

107 logger.debug(f"Starting warning calculation - provider={provider}") 

108 

109 is_local = provider in LOCAL_PROVIDERS 

110 

111 # --- Hardware / settings checks (pure functions) --- 

112 w = _safe_check( 

113 check_high_context, 

114 provider, 

115 local_context, 

116 dismiss_high_context, 

117 ) 

118 if w: 

119 warnings.append(w) 

120 

121 w = _safe_check( 

122 check_model_mismatch, 

123 provider, 

124 current_model, 

125 local_context, 

126 dismiss_model_mismatch, 

127 ) 

128 if w: 

129 warnings.append(w) 

130 

131 w = _safe_check(check_legacy_server_config, dismiss_legacy_config) 

132 if w: 

133 warnings.append(w) 

134 

135 # --- Egress policy checks --- 

136 egress_scope = settings_manager.get_setting( 

137 "policy.egress_scope", DEFAULT_EGRESS_SCOPE 

138 ) 

139 require_local_endpoint = bool( 

140 settings_manager.get_setting( 

141 "llm.require_local_endpoint", False 

142 ) 

143 ) 

144 embeddings_provider = settings_manager.get_setting( 

145 "embeddings.provider", "" 

146 ) 

147 embeddings_base_url = settings_manager.get_setting( 

148 "embeddings.openai.base_url", "" 

149 ) 

150 require_local_embeddings = bool( 

151 settings_manager.get_setting("embeddings.require_local", False) 

152 ) 

153 primary_engine = settings_manager.get_setting( 

154 "search.tool", DEFAULT_SEARCH_TOOL 

155 ) 

156 trusted_inference = settings_manager.get_setting( 

157 "policy.trusted_inference_providers", [] 

158 ) 

159 trusted_search = settings_manager.get_setting( 

160 "policy.trusted_search_engines", [] 

161 ) 

162 

163 # Resolve the EFFECTIVE posture so the banners are accurate. For 

164 # `adaptive`, this turns the opaque "follows the primary" into a 

165 # concrete scope; it also applies the PRIVATE_ONLY -> force-local 

166 # coupling, so a private-resolving run doesn't falsely show the 

167 # "cloud LLM enabled" banner. Best-effort: any failure falls back 

168 # to the raw values (the page must never break on this). 

169 effective_scope = str(egress_scope).lower() 

170 effective_require_local_endpoint = require_local_endpoint 

171 effective_require_local_embeddings = require_local_embeddings 

172 try: 

173 from ...security.egress.policy import context_from_snapshot 

174 

175 _snap = settings_manager.get_settings_snapshot() 

176 if isinstance(_snap, dict): 

177 # allow_dns=False: this runs on the /api/warnings page- 

178 # render hot path; skip the synchronous getaddrinfo that 

179 # ADAPTIVE resolution would otherwise do for a URL-engine 

180 # primary (could block the render up to _DNS_TIMEOUT_SEC). 

181 # The banner is advisory and falls back to static 

182 # classification — accuracy here is best-effort by design. 

183 _eff_ctx = context_from_snapshot( 

184 _snap, 

185 primary_engine or DEFAULT_SEARCH_TOOL, 

186 username=username, 

187 allow_dns=False, 

188 ) 

189 effective_scope = _eff_ctx.scope.value 

190 effective_require_local_endpoint = ( 

191 _eff_ctx.require_local_llm 

192 ) 

193 effective_require_local_embeddings = ( 

194 _eff_ctx.require_local_embeddings 

195 ) 

196 except Exception: 

197 logger.debug( 

198 "could not resolve effective egress scope for warnings", 

199 exc_info=True, 

200 ) 

201 

202 adaptive_info_dismissed = bool( 

203 settings_manager.get_setting( 

204 "app.warnings.dismiss_adaptive_scope_info", False 

205 ) 

206 ) 

207 

208 # Each egress banner has its OWN dismiss flag. Previously all 

209 # three shared app.warnings.dismiss_egress_policy, so dismissing 

210 # the fresh-install "public egress" notice ALSO permanently hid 

211 # the critical cloud-LLM / cloud-embeddings warnings — a 

212 # false-safety trap (switch to OpenAI later, never warned). 

213 public_egress_dismissed = bool( 

214 settings_manager.get_setting( 

215 "app.warnings.dismiss_egress_policy", False 

216 ) 

217 ) 

218 cloud_llm_dismissed = bool( 

219 settings_manager.get_setting( 

220 "app.warnings.dismiss_cloud_llm", False 

221 ) 

222 ) 

223 cloud_embeddings_dismissed = bool( 

224 settings_manager.get_setting( 

225 "app.warnings.dismiss_cloud_embeddings", False 

226 ) 

227 ) 

228 

229 # Informational: state what ADAPTIVE actually resolves to. 

230 w = _safe_check( 

231 check_effective_scope, 

232 egress_scope, 

233 effective_scope, 

234 primary_engine, 

235 adaptive_info_dismissed, 

236 ) 

237 if w: 

238 warnings.append(w) 

239 

240 w = _safe_check( 

241 check_public_egress_enabled, 

242 effective_scope, 

243 public_egress_dismissed, 

244 ) 

245 if w: 

246 warnings.append(w) 

247 

248 # Loud, non-dismissible banner when protection is turned off. 

249 w = _safe_check(check_unprotected_egress, effective_scope) 

250 if w: 

251 warnings.append(w) 

252 

253 # Trusted off-machine destinations (stage D) relax classification. 

254 w = _safe_check( 

255 check_trusted_destinations, trusted_inference, trusted_search 

256 ) 

257 if w: 257 ↛ 258line 257 didn't jump to line 258 because the condition on line 257 was never true

258 warnings.append(w) 

259 

260 w = _safe_check( 

261 check_cloud_llm_enabled, 

262 provider, 

263 effective_require_local_endpoint, 

264 cloud_llm_dismissed, 

265 ) 

266 if w: 

267 warnings.append(w) 

268 

269 w = _safe_check( 

270 check_cloud_embeddings_enabled, 

271 embeddings_provider, 

272 embeddings_base_url, 

273 effective_require_local_embeddings, 

274 cloud_embeddings_dismissed, 

275 ) 

276 if w: 276 ↛ 277line 276 didn't jump to line 277 because the condition on line 276 was never true

277 warnings.append(w) 

278 

279 # Engine-URL misconfiguration banners, driven entirely by the 

280 # engine declarations (url_setting + is_public/is_local) that 

281 # guarded_engine_url_descriptors() collects from the registry: 

282 # - a PUBLIC engine (SearXNG) whose private instance URL lacks 

283 # operator approval silently self-disables at run time; 

284 # - a LOCAL document engine (Paperless, Elasticsearch) whose 

285 # URL looks public would send document queries off-box and 

286 # is excluded under a private-only scope (fail-up). 

287 # Display names, activation flags, and dismiss keys follow the 

288 # per-engine settings conventions, so a future engine is covered 

289 # by declaring url_setting — not by editing this file. 

290 # A failure in this loop (settings read, descriptor sweep) must 

291 # not swallow the later backup/history banners, so it gets its 

292 # own guard rather than relying on the function-wide except. 

293 try: 

294 primary_lower = str(primary_engine or "").lower() 

295 from ...security.egress.validators import ( 

296 guarded_engine_url_descriptors, 

297 ) 

298 

299 for desc in guarded_engine_url_descriptors(): 

300 name = desc.engine_name 

301 base = f"search.engine.web.{name}" 

302 display = ( 

303 settings_manager.get_setting(f"{base}.display_name", "") 

304 or name.replace("_", " ").title() 

305 ) 

306 url_value = settings_manager.get_setting( 

307 desc.url_setting, "" 

308 ) 

309 engine_active = primary_lower == name or any( 

310 bool( 

311 settings_manager.get_setting( 

312 f"{base}.{flag}", False 

313 ) 

314 ) 

315 for flag in ( 

316 "enabled", 

317 "use_in_auto_search", 

318 "agent_enabled", 

319 ) 

320 ) 

321 if desc.is_public: 

322 w = _safe_check( 

323 check_private_engine_url_blocked, 

324 display_name=display, 

325 engine_name=name, 

326 url_setting=desc.url_setting, 

327 instance_url=url_value, 

328 active=engine_active, 

329 acknowledged=bool( 

330 settings_manager.get_setting( 

331 f"app.warnings.dismiss_{name}_private_url", 

332 False, 

333 ) 

334 ), 

335 ) 

336 else: 

337 # Engine-specific public-endpoint indicator: an 

338 # Elasticsearch cloud_id always targets Elastic 

339 # Cloud, regardless of what the hosts list says. 

340 extra = "" 

341 if name == "elasticsearch": 

342 cloud_id = settings_manager.get_setting( 

343 f"{base}.default_params.cloud_id", "" 

344 ) 

345 if str(cloud_id or "").strip(): 

346 extra = "Elastic Cloud (cloud_id)" 

347 w = _safe_check( 

348 check_local_engine_public_url, 

349 display_name=display, 

350 engine_name=name, 

351 urls=url_value, 

352 active=engine_active, 

353 acknowledged=bool( 

354 settings_manager.get_setting( 

355 f"app.warnings.dismiss_{name}_public_url", 

356 False, 

357 ) 

358 ), 

359 extra=extra, 

360 url_setting=desc.url_setting, 

361 ) 

362 if w: 

363 warnings.append(w) 

364 except Exception: 

365 # logger.exception, not debug: if the descriptor sweep 

366 # breaks (e.g. an engine-import regression) the whole 

367 # banner feature goes dark — that must be visible in 

368 # production logs, matching _safe_check's severity bar. 

369 logger.exception("engine-URL warning banners skipped") 

370 

371 # --- Backup checks --- 

372 w = _safe_check( 

373 check_backup_disabled, backup_enabled, dismiss_backup_disabled 

374 ) 

375 if w: 375 ↛ 376line 375 didn't jump to line 376 because the condition on line 375 was never true

376 warnings.append(w) 

377 

378 # Check backup file status (lightweight filesystem glob) 

379 dismiss_backup_info = settings_manager.get_setting( 

380 "app.warnings.dismiss_backup_info", False 

381 ) 

382 try: 

383 from ...config.paths import get_user_backup_directory 

384 from ...utilities.formatting import human_size 

385 from ...database.backup.backup_service import ( 

386 is_safe_glob_result, 

387 ) 

388 

389 if username: 389 ↛ 427line 389 didn't jump to line 427 because the condition on line 389 was always true

390 backup_dir = get_user_backup_directory(username) 

391 total_size = 0 

392 backup_count = 0 

393 for f in backup_dir.glob("ldr_backup_*.db"): 

394 # Skip symlinks / entries resolving outside backup_dir 

395 # so a planted symlink can't inflate the count/size 

396 # shown in warnings — same hardening as BackupService. 

397 if not is_safe_glob_result(f, backup_dir): 397 ↛ 399line 397 didn't jump to line 399 because the condition on line 397 was always true

398 continue 

399 try: 

400 total_size += f.stat().st_size 

401 backup_count += 1 

402 except FileNotFoundError: 

403 continue 

404 

405 w = _safe_check( 

406 check_no_backups_exist, 

407 backup_enabled, 

408 backup_count, 

409 dismiss_no_backups, 

410 ) 

411 if w: 411 ↛ 414line 411 didn't jump to line 414 because the condition on line 411 was always true

412 warnings.append(w) 

413 

414 w = _safe_check( 

415 check_backup_healthy, 

416 backup_enabled, 

417 backup_count, 

418 human_size(total_size), 

419 dismiss_backup_info, 

420 ) 

421 if w: 421 ↛ 422line 421 didn't jump to line 422 because the condition on line 421 was never true

422 warnings.append(w) 

423 except Exception: 

424 logger.debug("Backup status check skipped") 

425 

426 # --- History-based checks (need DB queries) --- 

427 if is_local and not dismiss_context_below_history: 

428 w = _safe_check( 

429 check_context_below_history, db_session, local_context 

430 ) 

431 if w: 

432 warnings.append(w) 

433 

434 if is_local and not dismiss_context_truncation_history: 

435 w = _safe_check( 

436 check_context_truncation_history, db_session, local_context 

437 ) 

438 if w: 

439 warnings.append(w) 

440 

441 except Exception: 

442 logger.exception("Error calculating warnings") 

443 

444 return warnings