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

131 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-19 23:35 +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 

8 

9from flask import session 

10from loguru import logger 

11 

12from ...database.session_context import get_user_db_session 

13from ...utilities.db_utils import get_settings_manager 

14from .context import ( 

15 check_context_below_history, 

16 check_context_truncation_history, 

17) 

18from .backup import ( 

19 check_backup_disabled, 

20 check_backup_healthy, 

21 check_no_backups_exist, 

22) 

23from .hardware import ( 

24 LOCAL_PROVIDERS, 

25 check_high_context, 

26 check_legacy_server_config, 

27 check_model_mismatch, 

28) 

29from ...security.egress.policy import DEFAULT_EGRESS_SCOPE 

30from ...security.egress.warnings import ( 

31 check_cloud_embeddings_enabled, 

32 check_cloud_llm_enabled, 

33 check_effective_scope, 

34 check_public_egress_enabled, 

35 check_trusted_destinations, 

36 check_unprotected_egress, 

37) 

38from ...constants import DEFAULT_SEARCH_TOOL 

39 

40 

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

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

43 try: 

44 return check_fn(*args, **kwargs) 

45 except Exception: 

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

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

48 return None 

49 

50 

51def calculate_warnings() -> List[dict]: 

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

53 

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

55 """ 

56 warnings: List[dict] = [] 

57 

58 try: 

59 username = session.get("username") 

60 with get_user_db_session(username) as db_session: 

61 if not db_session: 

62 return [] 

63 

64 settings_manager = get_settings_manager(db_session, username) 

65 

66 # Read all needed settings in one session 

67 provider = settings_manager.get_setting( 

68 "llm.provider", "ollama" 

69 ).lower() 

70 local_context = settings_manager.get_setting( 

71 "llm.local_context_window_size", 8192 

72 ) 

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

74 dismiss_high_context = settings_manager.get_setting( 

75 "app.warnings.dismiss_high_context", False 

76 ) 

77 dismiss_model_mismatch = settings_manager.get_setting( 

78 "app.warnings.dismiss_model_mismatch", False 

79 ) 

80 dismiss_context_below_history = settings_manager.get_setting( 

81 "app.warnings.dismiss_context_below_history", False 

82 ) 

83 dismiss_context_truncation_history = settings_manager.get_setting( 

84 "app.warnings.dismiss_context_truncation_history", False 

85 ) 

86 dismiss_legacy_config = settings_manager.get_setting( 

87 "app.warnings.dismiss_legacy_config", False 

88 ) 

89 backup_enabled = settings_manager.get_setting( 

90 "backup.enabled", True 

91 ) 

92 dismiss_backup_disabled = settings_manager.get_setting( 

93 "app.warnings.dismiss_backup_disabled", False 

94 ) 

95 dismiss_no_backups = settings_manager.get_setting( 

96 "app.warnings.dismiss_no_backups", False 

97 ) 

98 

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

100 

101 is_local = provider in LOCAL_PROVIDERS 

102 

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

104 w = _safe_check( 

105 check_high_context, 

106 provider, 

107 local_context, 

108 dismiss_high_context, 

109 ) 

110 if w: 

111 warnings.append(w) 

112 

113 w = _safe_check( 

114 check_model_mismatch, 

115 provider, 

116 current_model, 

117 local_context, 

118 dismiss_model_mismatch, 

119 ) 

120 if w: 

121 warnings.append(w) 

122 

123 w = _safe_check(check_legacy_server_config, dismiss_legacy_config) 

124 if w: 

125 warnings.append(w) 

126 

127 # --- Egress policy checks --- 

128 egress_scope = settings_manager.get_setting( 

129 "policy.egress_scope", DEFAULT_EGRESS_SCOPE 

130 ) 

131 require_local_endpoint = bool( 

132 settings_manager.get_setting( 

133 "llm.require_local_endpoint", False 

134 ) 

135 ) 

136 embeddings_provider = settings_manager.get_setting( 

137 "embeddings.provider", "" 

138 ) 

139 embeddings_base_url = settings_manager.get_setting( 

140 "embeddings.openai.base_url", "" 

141 ) 

142 require_local_embeddings = bool( 

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

144 ) 

145 primary_engine = settings_manager.get_setting( 

146 "search.tool", DEFAULT_SEARCH_TOOL 

147 ) 

148 trusted_inference = settings_manager.get_setting( 

149 "policy.trusted_inference_providers", [] 

150 ) 

151 trusted_search = settings_manager.get_setting( 

152 "policy.trusted_search_engines", [] 

153 ) 

154 

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

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

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

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

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

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

161 effective_scope = str(egress_scope).lower() 

162 effective_require_local_endpoint = require_local_endpoint 

163 effective_require_local_embeddings = require_local_embeddings 

164 try: 

165 from ...security.egress.policy import context_from_snapshot 

166 

167 _snap = settings_manager.get_settings_snapshot() 

168 if isinstance(_snap, dict): 

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

170 # render hot path; skip the synchronous getaddrinfo that 

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

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

173 # The banner is advisory and falls back to static 

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

175 _eff_ctx = context_from_snapshot( 

176 _snap, 

177 primary_engine or DEFAULT_SEARCH_TOOL, 

178 username=username, 

179 allow_dns=False, 

180 ) 

181 effective_scope = _eff_ctx.scope.value 

182 effective_require_local_endpoint = ( 

183 _eff_ctx.require_local_llm 

184 ) 

185 effective_require_local_embeddings = ( 

186 _eff_ctx.require_local_embeddings 

187 ) 

188 except Exception: 

189 logger.debug( 

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

191 exc_info=True, 

192 ) 

193 

194 adaptive_info_dismissed = bool( 

195 settings_manager.get_setting( 

196 "app.warnings.dismiss_adaptive_scope_info", False 

197 ) 

198 ) 

199 

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

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

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

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

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

205 public_egress_dismissed = bool( 

206 settings_manager.get_setting( 

207 "app.warnings.dismiss_egress_policy", False 

208 ) 

209 ) 

210 cloud_llm_dismissed = bool( 

211 settings_manager.get_setting( 

212 "app.warnings.dismiss_cloud_llm", False 

213 ) 

214 ) 

215 cloud_embeddings_dismissed = bool( 

216 settings_manager.get_setting( 

217 "app.warnings.dismiss_cloud_embeddings", False 

218 ) 

219 ) 

220 

221 # Informational: state what ADAPTIVE actually resolves to. 

222 w = _safe_check( 

223 check_effective_scope, 

224 egress_scope, 

225 effective_scope, 

226 primary_engine, 

227 adaptive_info_dismissed, 

228 ) 

229 if w: 

230 warnings.append(w) 

231 

232 w = _safe_check( 

233 check_public_egress_enabled, 

234 effective_scope, 

235 public_egress_dismissed, 

236 ) 

237 if w: 

238 warnings.append(w) 

239 

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

241 w = _safe_check(check_unprotected_egress, egress_scope) 

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

243 warnings.append(w) 

244 

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

246 w = _safe_check( 

247 check_trusted_destinations, trusted_inference, trusted_search 

248 ) 

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

250 warnings.append(w) 

251 

252 w = _safe_check( 

253 check_cloud_llm_enabled, 

254 provider, 

255 effective_require_local_endpoint, 

256 cloud_llm_dismissed, 

257 ) 

258 if w: 

259 warnings.append(w) 

260 

261 w = _safe_check( 

262 check_cloud_embeddings_enabled, 

263 embeddings_provider, 

264 embeddings_base_url, 

265 effective_require_local_embeddings, 

266 cloud_embeddings_dismissed, 

267 ) 

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

269 warnings.append(w) 

270 

271 # --- Backup checks --- 

272 w = _safe_check( 

273 check_backup_disabled, backup_enabled, dismiss_backup_disabled 

274 ) 

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

276 warnings.append(w) 

277 

278 # Check backup file status (lightweight filesystem glob) 

279 dismiss_backup_info = settings_manager.get_setting( 

280 "app.warnings.dismiss_backup_info", False 

281 ) 

282 try: 

283 from ...config.paths import get_user_backup_directory 

284 from ...utilities.formatting import human_size 

285 from ...database.backup.backup_service import ( 

286 is_safe_glob_result, 

287 ) 

288 

289 username = session.get("username") 

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

291 backup_dir = get_user_backup_directory(username) 

292 total_size = 0 

293 backup_count = 0 

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

295 # Skip symlinks / entries resolving outside backup_dir 

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

297 # shown in warnings — same hardening as BackupService. 

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

299 continue 

300 try: 

301 total_size += f.stat().st_size 

302 backup_count += 1 

303 except FileNotFoundError: 

304 continue 

305 

306 w = _safe_check( 

307 check_no_backups_exist, 

308 backup_enabled, 

309 backup_count, 

310 dismiss_no_backups, 

311 ) 

312 if w: 

313 warnings.append(w) 

314 

315 w = _safe_check( 

316 check_backup_healthy, 

317 backup_enabled, 

318 backup_count, 

319 human_size(total_size), 

320 dismiss_backup_info, 

321 ) 

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

323 warnings.append(w) 

324 except Exception: 

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

326 

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

328 if is_local and not dismiss_context_below_history: 

329 w = _safe_check( 

330 check_context_below_history, db_session, local_context 

331 ) 

332 if w: 

333 warnings.append(w) 

334 

335 if is_local and not dismiss_context_truncation_history: 

336 w = _safe_check( 

337 check_context_truncation_history, db_session, local_context 

338 ) 

339 if w: 

340 warnings.append(w) 

341 

342 except Exception: 

343 logger.exception("Error calculating warnings") 

344 

345 return warnings