Coverage for src/local_deep_research/web/api.py: 98%

204 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-19 23:35 +0000

1""" 

2REST API for Local Deep Research. 

3Provides HTTP access to programmatic search and research capabilities. 

4""" 

5 

6import inspect 

7import os 

8import threading 

9import time 

10from functools import wraps 

11from typing import Dict, Any, Optional, Tuple 

12 

13try: 

14 import resource as _resource_mod 

15except ImportError: 

16 _resource_mod = None # Windows: no POSIX resource limits 

17 

18from flask import Blueprint, jsonify, request, Response 

19from loguru import logger 

20 

21from ..api.research_functions import analyze_documents 

22from ..database.session_context import get_user_db_session 

23from ..security.decorators import require_json_body 

24from ..security.log_sanitizer import sanitize_error_for_client 

25from ..utilities.db_utils import get_settings_manager 

26from ..security.rate_limiter import ( 

27 API_RATE_LIMIT_DEFAULT, 

28 api_rate_limit, 

29 get_current_username, 

30) 

31 

32# Match the largest strategy-layer cap (_TOOL_ERROR_MAX_LEN = 500 in 

33# langgraph_agent_strategy.py) at the HTTP boundary so a message already 

34# scrubbed at the strategy layer is never re-truncated here, and 

35# categorizable exception tokens (e.g. "Connection refused" sitting deep 

36# in a long error) survive to the API client. The 200-char default of 

37# sanitize_error_for_client would truncate that signal prematurely. 

38_ERROR_BOUNDARY_MAX_LEN = 500 

39 

40 

41def _scrub_error_fields(results: Dict[str, Any]) -> None: 

42 """In-place defense-in-depth scrub for exception-derived fields about 

43 to leave via jsonify (CWE-209, CodeQL #8019). 

44 

45 Strategy-layer `_scrub_tool_error`/`sanitize_error_for_client` already 

46 wraps exception text at the source; this is the final HTTP boundary. 

47 Only fires on fields that start with the literal ``"Error:"`` marker 

48 so legitimate research prose is never touched, and truncation is from 

49 the tail so the marker survives the scrub. 

50 

51 Field names: strategies emit error text into ``current_knowledge``, 

52 but ``quick_summary()`` (research_functions.py) returns that value 

53 under the key ``summary`` — and ``analyze_documents()`` uses 

54 ``summary`` as well — so both spellings are scrubbed here. 

55 """ 

56 for field in ("current_knowledge", "summary", "formatted_findings"): 

57 value = results.get(field) 

58 if isinstance(value, str) and value.startswith("Error:"): 

59 results[field] = sanitize_error_for_client( 

60 value, max_length=_ERROR_BOUNDARY_MAX_LEN 

61 ) 

62 for finding in results.get("findings", []): 

63 content = finding.get("content") 

64 if isinstance(content, str) and content.startswith("Error:"): 

65 finding["content"] = sanitize_error_for_client( 

66 content, max_length=_ERROR_BOUNDARY_MAX_LEN 

67 ) 

68 

69 

70# Create a blueprint for the API 

71api_blueprint = Blueprint("api_v1", __name__, url_prefix="/api/v1") 

72 

73# Body params /analyze_documents accepts beyond the positional 

74# query/collection_name. Derived from the real signature so the two can't 

75# drift: analyze_documents (unlike quick_summary/generate_report) has no 

76# **kwargs, so an unknown key would TypeError at call time — surfacing as 

77# an opaque 500. Validating up front turns that into a clear 400. 

78# username/settings_snapshot are excluded because they are server-set by 

79# _load_user_context_into_params (and overwritten if a body supplied 

80# them); programmatic_mode stays accepted as the documented body 

81# override. 

82_ANALYZE_DOCUMENTS_PARAMS = frozenset( 

83 inspect.signature(analyze_documents).parameters 

84) - {"query", "collection_name", "username", "settings_snapshot"} 

85 

86 

87# Contract enforced by the test_user_context_loaded / 

88# test_settings_snapshot_loaded tests in tests/web/test_api_coverage.py. 

89# Each authed REST endpoint that calls a research function must invoke 

90# this helper. Each endpoint's contract test must assert that username, 

91# settings_snapshot (with the tracer key from _mock_access_control), and 

92# programmatic_mode=False reach the underlying research function. 

93def _load_user_context_into_params( 

94 params: Dict[str, Any], 

95 username: str | None, 

96 allow_default_settings: bool = False, 

97) -> Optional[Tuple[Response, int]]: 

98 """Mutate ``params`` in place to thread the authenticated user's context 

99 down to the research-function call. 

100 

101 All authenticated REST endpoints share the same shape: the user has an 

102 encrypted DB whose settings snapshot must be loaded and passed through, 

103 so calls honor the user's stored API keys, model preference, search 

104 tool, and other config — not just the application defaults plus 

105 ``LDR_*`` env vars that the programmatic-API fallback would produce. 

106 

107 Sets ``username``, ``settings_snapshot``, and (for authenticated 

108 requests) ``programmatic_mode=False`` so DB-backed rate-limit 

109 estimates persist across requests. Uses ``setdefault`` for 

110 ``programmatic_mode`` so an explicit override in the request body 

111 is respected. 

112 

113 Returns ``None`` on success. If the settings snapshot cannot be loaded, 

114 fails CLOSED: returns a ``(response, 503)`` tuple the endpoint must 

115 return to the caller. Continuing with an empty snapshot would resolve 

116 to the permissive default egress scope, silently downgrading a 

117 configured PRIVATE_ONLY / require-local user — bypassing the very 

118 boundary they configured. ``allow_default_settings=True`` is the 

119 caller's CONSCIOUS opt-in to proceed with defaults (empty snapshot, 

120 no egress policy) instead; it is logged loudly so it is never silent. 

121 """ 

122 if not username: 122 ↛ 123line 122 didn't jump to line 123 because the condition on line 122 was never true

123 logger.debug("No username in session, skipping settings snapshot") 

124 params["settings_snapshot"] = {} 

125 return None 

126 

127 params["username"] = username 

128 params.setdefault("programmatic_mode", False) 

129 try: 

130 with get_user_db_session(username) as db_session: 

131 if db_session is None: 

132 logger.warning(f"No database session for user: {username}") 

133 params["settings_snapshot"] = {} 

134 return None 

135 settings_manager = get_settings_manager(db_session, username) 

136 snapshot = settings_manager.get_settings_snapshot() 

137 params["settings_snapshot"] = snapshot 

138 logger.debug( 

139 f"Loaded settings snapshot for user '{username}' " 

140 f"with {len(snapshot)} settings" 

141 ) 

142 return None 

143 except Exception: 

144 # logger.exception captures the traceback so the root cause 

145 # (e.g. SQLCipher decrypt failure, settings table corruption, 

146 # missing column after a migration) is visible. Without this 

147 # the downstream error misleads — looks like "no provider", 

148 # really was "couldn't read user settings". 

149 logger.exception("Failed to load user settings snapshot") 

150 if allow_default_settings: 

151 # Caller explicitly opted in to run without their settings. 

152 # Proceed with defaults (empty snapshot → permissive scope). 

153 # Logged loudly so it's never a silent downgrade. 

154 logger.bind(policy_audit=True).warning( 

155 "Settings snapshot failed to load; proceeding with " 

156 "DEFAULT settings because allow_default_settings=true " 

157 "— this run is NOT bound by the user's egress policy", 

158 user=username, 

159 ) 

160 params["settings_snapshot"] = {} 

161 return None 

162 return ( 

163 jsonify( 

164 { 

165 "error": ( 

166 "Your settings could not be loaded, so the " 

167 "research was REFUSED to avoid silently " 

168 "running without your privacy/egress policy " 

169 "(which could send your data to the cloud " 

170 "when you meant to keep it local)." 

171 ), 

172 "how_to_fix": ( 

173 "This is usually transient — try again. If " 

174 "it persists, your encrypted settings " 

175 "database may be unavailable (e.g. a session " 

176 "/ password issue), so re-authenticate. To " 

177 "deliberately run with default settings and " 

178 "NO egress policy, resend the request with " 

179 '"allow_default_settings": true.' 

180 ), 

181 "reason": "settings_unavailable", 

182 } 

183 ), 

184 503, 

185 ) 

186 

187 

188def api_access_control(f): 

189 """ 

190 Decorator to enforce API access control: 

191 - Check if user is authenticated 

192 - Check if API is enabled for the user 

193 - Pre-cache api_rate_limit on g so the rate limiter avoids a second DB read 

194 """ 

195 

196 @wraps(f) 

197 def decorated_function(*args, **kwargs): 

198 from flask import g 

199 

200 username = get_current_username() 

201 

202 if not username: 

203 return jsonify({"error": "Authentication required"}), 401 

204 

205 # Read both settings in a single DB session 

206 api_enabled = True 

207 with get_user_db_session(username) as db_session: 

208 if db_session: 

209 settings_manager = get_settings_manager(db_session, username) 

210 api_enabled = settings_manager.get_setting( 

211 "app.enable_api", True 

212 ) 

213 # Pre-cache for _get_user_api_rate_limit() to avoid a second DB read 

214 g._api_rate_limit = settings_manager.get_setting( 

215 "app.api_rate_limit", API_RATE_LIMIT_DEFAULT 

216 ) 

217 

218 if not api_enabled: 

219 return jsonify({"error": "API access is disabled"}), 403 

220 

221 return f(*args, **kwargs) 

222 

223 return decorated_function 

224 

225 

226@api_blueprint.route("/", methods=["GET"]) 

227@api_access_control 

228@api_rate_limit 

229def api_documentation(): 

230 """ 

231 Provide documentation on the available API endpoints. 

232 """ 

233 api_docs = { 

234 "api_version": "v1", 

235 "description": "REST API for Local Deep Research", 

236 "endpoints": [ 

237 { 

238 "path": "/api/v1/quick_summary", 

239 "method": "POST", 

240 "description": "Generate a quick research summary", 

241 "parameters": { 

242 "query": "Research query (required)", 

243 "search_tool": "Search engine to use (optional)", 

244 "iterations": "Number of search iterations (optional)", 

245 "temperature": "LLM temperature (optional)", 

246 "allow_default_settings": "Set to true to proceed with default settings (and NO egress policy) when your stored settings cannot be loaded; default is to refuse with 503 (optional)", 

247 }, 

248 }, 

249 { 

250 "path": "/api/v1/generate_report", 

251 "method": "POST", 

252 "description": "Generate a comprehensive research report", 

253 "parameters": { 

254 "query": "Research query (required)", 

255 "output_file": "Path to save report (optional)", 

256 "searches_per_section": "Searches per report section (optional)", 

257 "model_name": "LLM model to use (optional)", 

258 "temperature": "LLM temperature (optional)", 

259 "allow_default_settings": "Set to true to proceed with default settings (and NO egress policy) when your stored settings cannot be loaded; default is to refuse with 503 (optional)", 

260 }, 

261 }, 

262 { 

263 "path": "/api/v1/analyze_documents", 

264 "method": "POST", 

265 "description": "Search and analyze documents in a local collection", 

266 "parameters": { 

267 "query": "Search query (required)", 

268 "collection_name": "Local collection name (required)", 

269 "max_results": "Maximum results to return (optional)", 

270 "temperature": "LLM temperature (optional)", 

271 "force_reindex": "Force collection reindexing (optional)", 

272 "allow_default_settings": "Set to true to proceed with default settings (and NO egress policy) when your stored settings cannot be loaded; default is to refuse with 503 (optional)", 

273 }, 

274 }, 

275 ], 

276 } 

277 

278 return jsonify(api_docs) 

279 

280 

281@api_blueprint.route("/health", methods=["GET"]) 

282def health_check(): 

283 """Health check endpoint with resource usage diagnostics. 

284 

285 The basic ``status``/``message``/``timestamp`` fields are public so the 

286 Docker healthcheck (which only inspects the HTTP status code) keeps 

287 working. File-descriptor and thread diagnostics are only included for 

288 authenticated users, to avoid leaking process internals to anonymous 

289 callers. 

290 """ 

291 diagnostics = { 

292 "status": "ok", 

293 "message": "API is running", 

294 "timestamp": time.time(), 

295 } 

296 

297 # Only expose resource diagnostics to authenticated users 

298 username = get_current_username() 

299 if username: 

300 # File descriptor count (Linux only; /proc not available on macOS) 

301 try: 

302 fd_count = len(os.listdir("/proc/self/fd")) 

303 except OSError: 

304 fd_count = None 

305 

306 # FD soft/hard limits (POSIX) 

307 soft_limit = hard_limit = None 

308 if _resource_mod is not None: 

309 try: 

310 soft_limit, hard_limit = _resource_mod.getrlimit( 

311 _resource_mod.RLIMIT_NOFILE 

312 ) 

313 if soft_limit == _resource_mod.RLIM_INFINITY: 

314 soft_limit = None 

315 if hard_limit == _resource_mod.RLIM_INFINITY: 

316 hard_limit = None 

317 except (AttributeError, ValueError, OSError): 

318 pass 

319 

320 thread_count = threading.active_count() 

321 

322 fd_usage_percent = ( 

323 round(fd_count / soft_limit * 100, 1) 

324 if fd_count is not None 

325 and soft_limit is not None 

326 and soft_limit > 0 

327 else None 

328 ) 

329 

330 diagnostics["resources"] = { 

331 "fd_count": fd_count, 

332 "fd_soft_limit": soft_limit, 

333 "fd_hard_limit": hard_limit, 

334 "fd_usage_percent": fd_usage_percent, 

335 "thread_count": thread_count, 

336 } 

337 

338 if fd_usage_percent is not None and fd_usage_percent > 70: 

339 diagnostics["status"] = "warning" 

340 diagnostics["message"] = ( 

341 f"High FD usage: {fd_count}/{soft_limit} ({fd_usage_percent}%)" 

342 ) 

343 

344 return jsonify(diagnostics) 

345 

346 

347def _serialize_results(results: Dict[str, Any]) -> Response: 

348 """ 

349 Converts the results dictionary into a JSON string. 

350 

351 Args: 

352 results: The results dictionary. 

353 

354 Returns: 

355 The JSON string. 

356 

357 """ 

358 # The main thing that needs to be handled here is the `Document` instances. 

359 converted_results = results.copy() 

360 for finding in converted_results.get("findings", []): 

361 for i, document in enumerate(finding.get("documents", [])): 

362 finding["documents"][i] = { 

363 "metadata": document.metadata, 

364 "content": document.page_content, 

365 } 

366 

367 # CWE-209 / CodeQL #8019: scrub exception-derived fields before 

368 # jsonify. See _scrub_error_fields for rationale. 

369 _scrub_error_fields(converted_results) 

370 

371 return jsonify(converted_results) 

372 

373 

374@api_blueprint.route("/quick_summary", methods=["POST"]) 

375@api_access_control 

376@api_rate_limit 

377@require_json_body(error_message="Query parameter is required") 

378def api_quick_summary(): 

379 """ 

380 Generate a quick research summary via REST API. 

381 

382 POST /api/v1/quick_summary 

383 { 

384 "query": "Advances in fusion energy research", 

385 "search_tool": "searxng", # Optional: search engine to use (defaults to your configured search.tool setting) 

386 "iterations": 2, # Optional: number of search iterations 

387 "temperature": 0.7 # Optional: LLM temperature 

388 } 

389 """ 

390 logger.debug("API quick_summary endpoint called") 

391 data = request.json 

392 logger.debug(f"Request data keys: {list(data.keys())}") 

393 

394 if "query" not in data: 

395 logger.debug("Missing query parameter") 

396 return jsonify({"error": "Query parameter is required"}), 400 

397 

398 # Extract query and validate type 

399 query = data.get("query") 

400 if not isinstance(query, str): 

401 return jsonify({"error": "Query must be a string"}), 400 

402 # Opt-in escape hatch for programmatic callers: when settings can't be 

403 # loaded, proceed with defaults (empty snapshot → permissive scope) instead 

404 # of failing closed (503). Default False (fail closed) so a configured 

405 # PRIVATE_ONLY user is never silently downgraded; setting it true is a 

406 # CONSCIOUS "I'm fine running without my settings/egress policy" choice. 

407 # Excluded from ``params`` so it isn't forwarded to quick_summary(). 

408 # Strict ``is True`` (not bool()): for a security-boundary flag we only opt 

409 # in on a real JSON ``true`` — not on a truthy string like "false"/"0". 

410 allow_default_settings = data.get("allow_default_settings") is True 

411 params = { 

412 k: v 

413 for k, v in data.items() 

414 if k not in ("query", "allow_default_settings") 

415 } 

416 logger.debug( 

417 f"Query length: {len(query) if query else 0}, params keys: {list(params.keys()) if params else 'None'}" 

418 ) 

419 

420 username = get_current_username() 

421 

422 try: 

423 # Import here to avoid circular imports. NOTE: get_user_db_session and 

424 # get_settings_manager are NOT re-imported here — both are bound at 

425 # module level (top of file). A local re-import would shadow the 

426 # module-level name and silently defeat ``patch("...web.api.<name>")`` 

427 # in tests (the function would fetch the real, encrypted-DB-requiring 

428 # implementation instead of the mock), so keep them module-level for one 

429 # consistent patch target. Only quick_summary genuinely needs the local 

430 # import (it pulls in the research stack, which would cycle). 

431 from ..api.research_functions import quick_summary 

432 

433 logger.info( 

434 f"Processing quick_summary request: query='{query}' for user='{username}'" 

435 ) 

436 

437 # Set reasonable defaults for API use. search_tool deliberately has 

438 # no default here: when omitted, quick_summary reads the user's 

439 # configured search.tool from the settings snapshot. 

440 params.setdefault("temperature", 0.7) 

441 params.setdefault("iterations", 1) 

442 

443 error = _load_user_context_into_params( 

444 params, username, allow_default_settings 

445 ) 

446 if error is not None: 

447 return error 

448 

449 # Call the actual research function 

450 result = quick_summary(query, **params) 

451 

452 return _serialize_results(result) 

453 except TimeoutError: 

454 logger.exception("Request timed out") 

455 return ( 

456 jsonify( 

457 { 

458 "error": "Request timed out. Please try with a simpler query or fewer iterations." 

459 } 

460 ), 

461 504, 

462 ) 

463 except Exception: 

464 logger.exception("Error in quick_summary API") 

465 return ( 

466 jsonify( 

467 { 

468 "error": "An internal error has occurred. Please try again later." 

469 } 

470 ), 

471 500, 

472 ) 

473 

474 

475@api_blueprint.route("/generate_report", methods=["POST"]) 

476@api_access_control 

477@api_rate_limit 

478@require_json_body(error_message="Query parameter is required") 

479def api_generate_report(): 

480 """ 

481 Generate a comprehensive research report via REST API. 

482 

483 POST /api/v1/generate_report 

484 { 

485 "query": "Impact of climate change on agriculture", 

486 "output_file": "/path/to/save/report.md", # Optional 

487 "searches_per_section": 2, # Optional 

488 "model_name": "gpt-4", # Optional 

489 "temperature": 0.5 # Optional 

490 } 

491 """ 

492 data = request.json 

493 if "query" not in data: 

494 return jsonify({"error": "Query parameter is required"}), 400 

495 

496 query = data.get("query") 

497 # See api_quick_summary for the allow_default_settings semantics 

498 # (opt-in escape hatch, strict ``is True``, excluded from params). 

499 allow_default_settings = data.get("allow_default_settings") is True 

500 params = { 

501 k: v 

502 for k, v in data.items() 

503 if k not in ("query", "allow_default_settings") 

504 } 

505 

506 username = get_current_username() 

507 

508 try: 

509 # Import here to avoid circular imports 

510 from ..api.research_functions import generate_report 

511 

512 # Set reasonable defaults for API use 

513 params.setdefault("searches_per_section", 1) 

514 params.setdefault("temperature", 0.7) 

515 

516 error = _load_user_context_into_params( 

517 params, username, allow_default_settings 

518 ) 

519 if error is not None: 

520 return error 

521 

522 logger.info( 

523 f"Processing generate_report request: query='{query}' for user='{username}'" 

524 ) 

525 

526 result = generate_report(query, **params) 

527 

528 # Don't return the full content for large reports 

529 if ( 

530 result 

531 and "content" in result 

532 and isinstance(result["content"], str) 

533 and len(result["content"]) > 10000 

534 ): 

535 # Include a summary of the report content 

536 content_preview = ( 

537 result["content"][:2000] + "... [Content truncated]" 

538 ) 

539 result["content"] = content_preview 

540 result["content_truncated"] = True 

541 

542 # CWE-209 / CodeQL #8019: same boundary scrub as _serialize_results. 

543 # generate_report bypasses that helper, so apply _scrub_error_fields 

544 # directly. NOTE: today's payload ({content, metadata, file_path}) 

545 # contains none of the scrubbed field names — exceptions propagate 

546 # to the generic handlers below instead — so this is purely 

547 # precautionary: it keeps the every-jsonify-sink boundary policy 

548 # intact if the payload shape ever grows error-carrying fields. 

549 _scrub_error_fields(result) 

550 

551 return jsonify(result) 

552 except TimeoutError: 

553 logger.exception("Request timed out") 

554 return ( 

555 jsonify( 

556 {"error": "Request timed out. Please try with a simpler query."} 

557 ), 

558 504, 

559 ) 

560 except Exception: 

561 logger.exception("Error in generate_report API") 

562 return ( 

563 jsonify( 

564 { 

565 "error": "An internal error has occurred. Please try again later." 

566 } 

567 ), 

568 500, 

569 ) 

570 

571 

572@api_blueprint.route("/analyze_documents", methods=["POST"]) 

573@api_access_control 

574@api_rate_limit 

575@require_json_body( 

576 error_message="Both query and collection_name parameters are required" 

577) 

578def api_analyze_documents(): 

579 """ 

580 Search and analyze documents in a local collection via REST API. 

581 

582 POST /api/v1/analyze_documents 

583 { 

584 "query": "neural networks in medicine", 

585 "collection_name": "my_collection", # Required: local collection name 

586 "max_results": 20, # Optional: max results to return 

587 "temperature": 0.7, # Optional: LLM temperature 

588 "force_reindex": false # Optional: force reindexing 

589 } 

590 """ 

591 data = request.json 

592 if "query" not in data or "collection_name" not in data: 

593 return ( 

594 jsonify( 

595 { 

596 "error": "Both query and collection_name parameters are required" 

597 } 

598 ), 

599 400, 

600 ) 

601 

602 query = data.get("query") 

603 collection_name = data.get("collection_name") 

604 # See api_quick_summary for the allow_default_settings semantics 

605 # (opt-in escape hatch, strict ``is True``, excluded from params). 

606 allow_default_settings = data.get("allow_default_settings") is True 

607 params = { 

608 k: v 

609 for k, v in data.items() 

610 if k not in ("query", "collection_name", "allow_default_settings") 

611 } 

612 

613 unknown_params = sorted(set(params) - _ANALYZE_DOCUMENTS_PARAMS) 

614 if unknown_params: 

615 return ( 

616 jsonify( 

617 { 

618 "error": ( 

619 f"Unknown parameter(s) for analyze_documents: " 

620 f"{', '.join(unknown_params)}" 

621 ), 

622 "allowed_parameters": sorted(_ANALYZE_DOCUMENTS_PARAMS), 

623 } 

624 ), 

625 400, 

626 ) 

627 

628 username = get_current_username() 

629 

630 try: 

631 error = _load_user_context_into_params( 

632 params, username, allow_default_settings 

633 ) 

634 if error is not None: 

635 return error 

636 result = analyze_documents(query, collection_name, **params) 

637 # CWE-209 / CodeQL #8019: same boundary scrub as _serialize_results. 

638 # The live field here is `summary` (analyze_documents returns 

639 # {summary, documents, ...} and puts "Error: ..." text in summary, 

640 # e.g. the unknown-collection message). 

641 _scrub_error_fields(result) 

642 return jsonify(result) 

643 except Exception: 

644 logger.exception("Error in analyze_documents API") 

645 return ( 

646 jsonify( 

647 { 

648 "error": "An internal error has occurred. Please try again later." 

649 } 

650 ), 

651 500, 

652 )