Coverage for src/local_deep_research/web/routes/api_routes.py: 98%
204 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-20 01:24 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-20 01:24 +0000
1import requests
2from flask import (
3 Blueprint,
4 current_app,
5 jsonify,
6 request,
7)
8from loguru import logger
10from ...database.models import QueuedResearch, ResearchHistory
11from ...database.session_context import get_user_db_session
12from ...config.constants import DEFAULT_OLLAMA_URL
13from ...constants import ResearchStatus
14from ...utilities.url_utils import normalize_url
15from ...security.decorators import require_json_body
16from ..auth.decorators import login_required
17from ..routes.research_routes import _research_not_found
18from ..services.research_service import (
19 cancel_research,
20)
21from ..services.resource_service import (
22 add_resource,
23 delete_resource,
24 get_resources_for_research,
25)
26from local_deep_research.settings import SettingsManager
27from ...llm.providers.base import normalize_provider
28from ...security import safe_get, strip_settings_snapshot
30# Create blueprint
31api_bp = Blueprint("api", __name__)
33# NOTE: Routes use session["username"] (not .get()) intentionally.
34# @login_required guarantees the key exists; direct access fails fast
35# if the decorator is ever removed.
38@api_bp.route("/settings/current-config", methods=["GET"])
39@login_required
40def get_current_config():
41 """Get the current configuration from database settings."""
42 try:
43 with get_user_db_session() as session:
44 settings_manager = SettingsManager(session)
45 config = {
46 "provider": settings_manager.get_setting(
47 "llm.provider", "Not configured"
48 ),
49 "model": settings_manager.get_setting(
50 "llm.model", "Not configured"
51 ),
52 "search_tool": settings_manager.get_setting(
53 "search.tool", "searxng"
54 ),
55 "iterations": settings_manager.get_setting(
56 "search.iterations", 8
57 ),
58 "questions_per_iteration": settings_manager.get_setting(
59 "search.questions_per_iteration", 5
60 ),
61 "search_strategy": settings_manager.get_setting(
62 "search.search_strategy", "focused_iteration"
63 ),
64 }
66 return jsonify({"success": True, "config": config})
68 except Exception:
69 logger.exception("Error getting current config")
70 return jsonify(
71 {"success": False, "error": "An internal error occurred"}
72 ), 500
75# API Routes
76@api_bp.route("/start", methods=["POST"])
77@login_required
78def api_start_research():
79 """
80 Start a new research process.
82 Delegates to the full-featured start_research() in research_routes,
83 which reads settings from the database, handles queueing, and starts
84 the research thread.
85 """
86 from ..routes.research_routes import start_research
88 return start_research()
91@api_bp.route("/status/<string:research_id>", methods=["GET"])
92@login_required
93def api_research_status(research_id):
94 """
95 Get the status of a research process
96 """
97 try:
98 # Get a fresh session to avoid conflicts with the research process
100 with get_user_db_session() as db_session:
101 research = (
102 db_session.query(ResearchHistory)
103 .filter_by(id=research_id)
104 .first()
105 )
107 if research is None:
108 return _research_not_found(research_id)
110 # Extract attributes while session is active
111 # to avoid DetachedInstanceError after the with block exits
112 result = {
113 "status": research.status,
114 "progress": research.progress,
115 "completed_at": research.completed_at,
116 "report_path": research.report_path,
117 "metadata": strip_settings_snapshot(research.research_meta),
118 }
120 # Include queue position for queued research
121 if research.status == ResearchStatus.QUEUED: 121 ↛ 122line 121 didn't jump to line 122 because the condition on line 121 was never true
122 queued = (
123 db_session.query(QueuedResearch)
124 .filter_by(research_id=research_id)
125 .first()
126 )
127 if queued:
128 result["queue_position"] = queued.position
130 return jsonify(result)
131 except Exception:
132 logger.exception("Error getting research status")
133 return jsonify(
134 {"status": "error", "message": "Failed to get research status"}
135 ), 500
138@api_bp.route("/terminate/<string:research_id>", methods=["POST"])
139@login_required
140def api_terminate_research(research_id):
141 """
142 Terminate a research process
143 """
144 try:
145 from flask import session
147 username = session["username"]
148 result = cancel_research(research_id, username)
149 if result:
150 return jsonify(
151 {
152 "status": "success",
153 "message": "Research terminated",
154 "result": result,
155 }
156 )
157 return jsonify(
158 {
159 "status": "success",
160 "message": "Research not found or already completed",
161 "result": result,
162 }
163 )
164 except Exception:
165 logger.exception("Error terminating research")
166 return (
167 jsonify({"status": "error", "message": "Failed to stop research."}),
168 500,
169 )
172@api_bp.route("/resources/<string:research_id>", methods=["GET"])
173@login_required
174def api_get_resources(research_id):
175 """
176 Get resources for a specific research
177 """
178 try:
179 resources = get_resources_for_research(research_id)
180 return jsonify({"status": "success", "resources": resources})
181 except Exception:
182 logger.exception("Error getting resources for research")
183 return jsonify(
184 {"status": "error", "message": "Failed to get resources"}
185 ), 500
188@api_bp.route("/resources/<string:research_id>", methods=["POST"])
189@login_required
190@require_json_body(error_format="status")
191def api_add_resource(research_id):
192 """
193 Add a new resource to a research project
194 """
195 try:
196 data = request.json
197 # Required fields
198 title = data.get("title")
199 url = data.get("url")
201 # Optional fields
202 content_preview = data.get("content_preview")
203 source_type = data.get("source_type", "web")
204 metadata = data.get("metadata", {})
206 # Validate required fields
207 if not title or not url:
208 return (
209 jsonify(
210 {"status": "error", "message": "Title and URL are required"}
211 ),
212 400,
213 )
215 # Security: Validate URL to prevent SSRF attacks
216 from ...security.ssrf_validator import validate_url
218 is_valid = validate_url(url)
219 if not is_valid:
220 logger.warning(f"SSRF protection: Rejected URL {url}")
221 return (
222 jsonify({"status": "error", "message": "Invalid URL"}),
223 400,
224 )
226 # Check if the research exists
227 with get_user_db_session() as db_session:
228 research = (
229 db_session.query(ResearchHistory)
230 .filter_by(id=research_id)
231 .first()
232 )
234 if not research:
235 return _research_not_found(research_id)
237 # Add the resource
238 resource_id = add_resource(
239 research_id=research_id,
240 title=title,
241 url=url,
242 content_preview=content_preview,
243 source_type=source_type,
244 metadata=metadata,
245 )
247 return jsonify(
248 {
249 "status": "success",
250 "message": "Resource added successfully",
251 "resource_id": resource_id,
252 }
253 )
254 except Exception:
255 logger.exception("Error adding resource")
256 return jsonify(
257 {"status": "error", "message": "Failed to add resource"}
258 ), 500
261@api_bp.route(
262 "/resources/<string:research_id>/delete/<int:resource_id>",
263 methods=["DELETE"],
264)
265@login_required
266def api_delete_resource(research_id, resource_id):
267 """
268 Delete a resource from a research project
269 """
270 try:
271 # Delete the resource
272 success = delete_resource(resource_id)
274 if success:
275 return jsonify(
276 {
277 "status": "success",
278 "message": "Resource deleted successfully",
279 }
280 )
281 return jsonify(
282 {"status": "error", "message": "Resource not found"}
283 ), 404
284 except Exception:
285 logger.exception("Error deleting resource")
286 return jsonify(
287 {
288 "status": "error",
289 "message": "An internal error occurred while deleting the resource.",
290 }
291 ), 500
294def _ollama_base_url_from_config(llm_config):
295 """Resolve the Ollama base URL from the LLM config (normalized, with the
296 default fallback). Single source so every Ollama probe targets the same
297 URL."""
298 raw = llm_config.get("ollama_base_url", DEFAULT_OLLAMA_URL)
299 return normalize_url(raw) if raw else DEFAULT_OLLAMA_URL
302def _probe_ollama_tags(base_url, timeout=5):
303 """Probe Ollama ``/api/tags`` once and classify the outcome.
305 Single source for the resolve→fetch→new/old-format-parse→error logic that
306 was previously copy-pasted across the status and model-availability checks
307 (so "is Ollama up?" can no longer answer differently per caller). Returns
308 ``(outcome, probe_result)`` where outcome is one of:
310 - ``"ok"`` → probe_result is the list of model dicts (both API formats handled)
311 - ``"bad_status"`` → probe_result is the non-200 status code
312 - ``"invalid_json"`` → probe_result is None (200 but unparseable body)
313 - ``"connection_error"`` / ``"timeout"`` → probe_result is None
315 Callers map the outcome onto their own response shape.
316 """
317 try:
318 response = safe_get(
319 f"{base_url}/api/tags",
320 timeout=timeout,
321 allow_localhost=True,
322 allow_private_ips=True,
323 )
324 except requests.exceptions.ConnectionError:
325 return "connection_error", None
326 except requests.exceptions.Timeout:
327 return "timeout", None
329 if response.status_code != 200:
330 return "bad_status", response.status_code
332 try:
333 data = response.json()
334 except ValueError:
335 return "invalid_json", None
337 # New Ollama API nests the list under "models"; the older format is a
338 # bare list. Mirror the previous inline check exactly (a bare ``"models"
339 # in data`` membership test, no isinstance guard) so behavior is identical
340 # to the pre-refactor endpoints — a malformed non-dict/non-list body
341 # raises here and is handled by each caller's outer except, as before.
342 if "models" in data:
343 models = data.get("models", [])
344 else:
345 models = data
346 return "ok", models
349@api_bp.route("/check/ollama_status", methods=["GET"])
350@login_required
351def check_ollama_status():
352 """
353 Check if Ollama API is running
354 """
355 try:
356 # Get Ollama URL from config
357 llm_config = current_app.config.get("LLM_CONFIG", {})
358 provider = normalize_provider(llm_config.get("provider", "ollama"))
360 if provider != "ollama":
361 return jsonify(
362 {
363 "running": True,
364 "message": f"Using provider: {provider}, not Ollama",
365 }
366 )
368 ollama_base_url = _ollama_base_url_from_config(llm_config)
369 logger.info(f"Checking Ollama status at: {ollama_base_url}")
371 outcome, probe_result = _probe_ollama_tags(ollama_base_url)
373 if outcome == "ok":
374 model_count = len(probe_result)
375 logger.info(f"Ollama service is running with {model_count} models")
376 return jsonify(
377 {
378 "running": True,
379 "message": f"Ollama service is running with {model_count} models",
380 "model_count": model_count,
381 }
382 )
383 if outcome == "invalid_json":
384 logger.warning("Ollama returned invalid JSON")
385 return jsonify(
386 {
387 "running": True,
388 "message": "Ollama service is running but returned invalid data format",
389 "error_details": "Invalid response format from the service.",
390 }
391 )
392 if outcome == "bad_status":
393 logger.warning(
394 f"Ollama returned non-200 status code: {probe_result}"
395 )
396 return jsonify(
397 {
398 "running": False,
399 "message": f"Ollama service returned status code: {probe_result}",
400 "status_code": probe_result,
401 }
402 )
403 if outcome == "connection_error":
404 logger.warning("Ollama connection error")
405 return jsonify(
406 {
407 "running": False,
408 "message": "Ollama service is not running or not accessible",
409 "error_type": "connection_error",
410 "error_details": "Unable to connect to the service. Please check if the service is running.",
411 }
412 )
413 # outcome == "timeout"
414 logger.warning("Ollama request timed out")
415 return jsonify(
416 {
417 "running": False,
418 "message": "Ollama service request timed out after 5 seconds",
419 "error_type": "timeout",
420 "error_details": "Request timed out. The service may be overloaded.",
421 }
422 )
424 except Exception:
425 logger.exception("Error checking Ollama status")
426 return jsonify(
427 {
428 "running": False,
429 "message": "An internal error occurred while checking Ollama status.",
430 "error_type": "exception",
431 "error_details": "An internal error occurred.",
432 }
433 )
436@api_bp.route("/check/ollama_model", methods=["GET"])
437@login_required
438def check_ollama_model():
439 """
440 Check if the configured Ollama model is available
441 """
442 try:
443 # Get Ollama configuration
444 llm_config = current_app.config.get("LLM_CONFIG", {})
445 provider = normalize_provider(llm_config.get("provider", "ollama"))
447 if provider != "ollama":
448 return jsonify(
449 {
450 "available": True,
451 "message": f"Using provider: {provider}, not Ollama",
452 "provider": provider,
453 }
454 )
456 # Get model name from request or use config default
457 model_name = request.args.get("model")
458 if not model_name:
459 model_name = llm_config.get("model", "")
461 if not model_name:
462 logger.warning(
463 "/api/check/ollama_model called with no model name and "
464 "llm.model is not configured"
465 )
466 return jsonify(
467 {
468 "available": False,
469 "model": "",
470 "message": (
471 "Model is required. Pass ?model=<name> in the "
472 "query string, or configure llm.model in Settings."
473 ),
474 "error_type": "model_not_configured",
475 }
476 ), 400
478 # Log which model we're checking for debugging
479 logger.info(f"Checking availability of Ollama model: {model_name}")
481 ollama_base_url = _ollama_base_url_from_config(llm_config)
483 outcome, probe_result = _probe_ollama_tags(ollama_base_url)
485 if outcome == "bad_status":
486 logger.warning(
487 f"Ollama API returned non-200 status: {probe_result}"
488 )
489 return jsonify(
490 {
491 "available": False,
492 "model": model_name,
493 "message": f"Could not access Ollama service - status code: {probe_result}",
494 "status_code": probe_result,
495 }
496 )
497 if outcome == "invalid_json":
498 logger.warning("Failed to parse Ollama API response")
499 return jsonify(
500 {
501 "available": False,
502 "model": model_name,
503 "message": "Invalid response from Ollama API",
504 "error_type": "json_parse_error",
505 }
506 )
507 if outcome == "connection_error":
508 logger.warning("Connection error to Ollama API")
509 return jsonify(
510 {
511 "available": False,
512 "model": model_name,
513 "message": "Could not connect to Ollama service",
514 "error_type": "connection_error",
515 "error_details": "Unable to connect to the service. Please check if the service is running.",
516 }
517 )
518 if outcome == "timeout":
519 logger.warning("Timeout connecting to Ollama API")
520 return jsonify(
521 {
522 "available": False,
523 "model": model_name,
524 "message": "Connection to Ollama service timed out",
525 "error_type": "timeout",
526 }
527 )
529 # outcome == "ok"
530 models = probe_result
531 model_names = [m.get("name", "") for m in models]
532 logger.debug(
533 f"Available Ollama models: {', '.join(model_names[:10])}"
534 + (
535 f" and {len(model_names) - 10} more"
536 if len(model_names) > 10
537 else ""
538 )
539 )
541 # Case-insensitive model name comparison
542 model_exists = any(
543 m.get("name", "").lower() == model_name.lower() for m in models
544 )
546 if model_exists:
547 logger.info(f"Ollama model {model_name} is available")
548 return jsonify(
549 {
550 "available": True,
551 "model": model_name,
552 "message": f"Model {model_name} is available",
553 "all_models": model_names,
554 }
555 )
556 # Check if models were found at all
557 if not models:
558 logger.warning("No models found in Ollama")
559 message = "No models found in Ollama. Please pull models first."
560 else:
561 logger.warning(
562 f"Model {model_name} not found among {len(models)} available models"
563 )
564 # Don't expose available models for security reasons
565 message = f"Model {model_name} is not available"
567 return jsonify(
568 {
569 "available": False,
570 "model": model_name,
571 "message": message,
572 # Remove all_models to prevent information disclosure
573 }
574 )
576 except Exception:
577 # General exception
578 logger.exception("Error checking Ollama model")
580 return jsonify(
581 {
582 "available": False,
583 "model": (
584 model_name
585 if "model_name" in locals()
586 else llm_config.get("model", "")
587 ),
588 "message": "An internal error occurred while checking the model.",
589 "error_type": "exception",
590 "error_details": "An internal error occurred.",
591 }
592 )