Coverage for src/local_deep_research/followup_research/routes.py: 89%
120 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
1"""
2Flask routes for follow-up research functionality.
3"""
5from flask import Blueprint, request, jsonify, session
6from loguru import logger
8from ..constants import ResearchStatus
9from ..exceptions import DuplicateResearchError, SystemAtCapacityError
10from ..llm.providers.base import normalize_provider
11from .service import FollowUpResearchService
12from .models import FollowUpRequest
13from ..utilities.url_utils import is_safe_custom_llm_endpoint
14from ..security.decorators import require_json_body
15from ..web.auth.decorators import login_required
16from ..web.auth.password_utils import resolve_user_password
18# Create blueprint
19followup_bp = Blueprint("followup", __name__, url_prefix="/api/followup")
21# NOTE: Routes use session["username"] (not .get()) intentionally.
22# @login_required guarantees the key exists; direct access fails fast
23# if the decorator is ever removed.
26@followup_bp.route("/prepare", methods=["POST"])
27@login_required
28@require_json_body(error_format="success")
29def prepare_followup():
30 """
31 Prepare a follow-up research by loading parent context.
33 Request body:
34 {
35 "parent_research_id": "uuid",
36 "question": "follow-up question"
37 }
39 Returns:
40 {
41 "success": true,
42 "parent_summary": "...",
43 "available_sources": 10,
44 "suggested_strategy": "source-based"
45 }
46 """
47 try:
48 data = request.get_json()
49 parent_id = data.get("parent_research_id")
50 question = data.get("question")
52 if not parent_id or not question:
53 return jsonify(
54 {
55 "success": False,
56 "error": "Missing parent_research_id or question",
57 }
58 ), 400
60 # Get username from session
61 username = session["username"]
63 # Get settings snapshot to use for suggested strategy
64 from ..settings.manager import SettingsManager
65 from ..database.session_context import get_user_db_session
67 with get_user_db_session(username) as db_session:
68 settings_manager = SettingsManager(db_session=db_session)
69 settings_snapshot = settings_manager.get_all_settings()
71 # Get strategy from settings
72 strategy_from_settings = settings_snapshot.get(
73 "search.search_strategy", {}
74 ).get("value", "source-based")
76 # Initialize service
77 service = FollowUpResearchService(username=username)
79 # Load parent context
80 parent_data = service.load_parent_research(parent_id)
82 if not parent_data:
83 logger.warning("Parent research {} not found", parent_id)
84 return jsonify(
85 {"success": False, "error": "Parent research not found"}
86 ), 404
88 # Prepare response with parent context summary
89 response = {
90 "success": True,
91 "parent_summary": parent_data.get("query", ""),
92 "available_sources": len(parent_data.get("resources", [])),
93 "suggested_strategy": strategy_from_settings, # Use strategy from settings
94 "parent_research": {
95 "id": parent_id,
96 "query": parent_data.get("query", ""),
97 "sources_count": len(parent_data.get("resources", [])),
98 },
99 }
101 return jsonify(response)
103 except Exception:
104 logger.exception("Error preparing follow-up")
105 return jsonify(
106 {"success": False, "error": "An internal error has occurred."}
107 ), 500
110@followup_bp.route("/start", methods=["POST"])
111@login_required
112@require_json_body(error_format="success")
113def start_followup():
114 """
115 Start a follow-up research.
117 Request body:
118 {
119 "parent_research_id": "uuid",
120 "question": "follow-up question",
121 "strategy": "source-based", # optional
122 "max_iterations": 1, # optional
123 "questions_per_iteration": 3 # optional
124 }
126 Returns:
127 {
128 "success": true,
129 "research_id": "new-uuid",
130 "message": "Follow-up research started"
131 }
132 """
133 try:
134 from ..web.services.research_service import (
135 start_research_process,
136 run_research_process,
137 )
138 import uuid
140 data = request.get_json()
142 # Get username from session
143 username = session["username"]
145 # Get settings snapshot first to use database values
146 from ..settings.manager import SettingsManager
147 from ..database.session_context import get_user_db_session
149 with get_user_db_session(username) as db_session:
150 settings_manager = SettingsManager(db_session=db_session)
151 settings_snapshot = settings_manager.get_all_settings()
153 # Get strategy from settings snapshot, fallback to source-based if not set
154 strategy_from_settings = settings_snapshot.get(
155 "search.search_strategy", {}
156 ).get("value", "source-based")
158 # Get iterations and questions from settings snapshot
159 iterations_from_settings = settings_snapshot.get(
160 "search.iterations", {}
161 ).get("value", 1)
162 questions_from_settings = settings_snapshot.get(
163 "search.questions_per_iteration", {}
164 ).get("value", 3)
166 # Create follow-up request using settings values
167 followup_request = FollowUpRequest(
168 parent_research_id=data.get("parent_research_id"),
169 question=data.get("question"),
170 strategy=strategy_from_settings, # Use strategy from settings
171 max_iterations=iterations_from_settings, # Use iterations from settings
172 questions_per_iteration=questions_from_settings, # Use questions from settings
173 )
175 # Initialize service
176 service = FollowUpResearchService(username=username)
178 # Prepare research parameters
179 research_params = service.perform_followup(followup_request)
181 logger.info(f"Research params type: {type(research_params)}")
182 logger.info(
183 f"Research params keys: {research_params.keys() if isinstance(research_params, dict) else 'Not a dict'}"
184 )
185 logger.info(
186 f"Query value: {research_params.get('query') if isinstance(research_params, dict) else 'N/A'}"
187 )
188 logger.info(
189 f"Query type: {type(research_params.get('query')) if isinstance(research_params, dict) else 'N/A'}"
190 )
192 # Get user password for metrics database access.
193 # Shared helper (password_utils) so every research entry point makes
194 # the same encryption-aware decision and logs it the same way.
195 # Must check BEFORE creating ResearchHistory to avoid orphaned records.
196 user_password, session_expired = resolve_user_password(username)
198 if session_expired:
199 # Use success/error keys to match followup API convention
200 # (the followup frontend checks data.success and data.error)
201 return jsonify(
202 {
203 "success": False,
204 "error": "Your session has expired. Please log out and log back in to start research.",
205 }
206 ), 401
208 # Pre-flight: refuse to spawn a research thread (and create an
209 # orphan ResearchHistory row) when llm.model is empty. Mirrors the
210 # empty-model check in research_routes.start_research — same
211 # contract: HTTP 400 with an actionable message before any DB
212 # writes or thread spawning. (This blueprint uses success/error
213 # response keys rather than status/message, matching the followup
214 # API convention used by the other returns in this function.)
215 if not settings_snapshot.get("llm.model", {}).get("value"):
216 logger.error(
217 "Follow-up research blocked: llm.model is not configured"
218 )
219 return jsonify(
220 {
221 "success": False,
222 "error": "Model is required. Please configure a model in the settings.",
223 }
224 ), 400
226 # SSRF pre-flight on the LLM endpoint: reject metadata / link-local
227 # targets at the request boundary, before any DB row is written.
228 # This is fail-fast defense-in-depth — the OpenAI-compatible provider's
229 # assert_base_url_safe re-validates the same URL before the client is
230 # built. Private IPs and localhost pass because local LLMs
231 # (Ollama / LM Studio / vLLM) live there, including scheme-less
232 # endpoints (the helper normalizes exactly as the provider does).
233 custom_endpoint = settings_snapshot.get(
234 "llm.openai_endpoint.url", {}
235 ).get("value")
236 if not is_safe_custom_llm_endpoint(custom_endpoint):
237 return (
238 jsonify(
239 {
240 "success": False,
241 "error": "Invalid custom endpoint URL",
242 }
243 ),
244 400,
245 )
247 # Generate new research ID
248 research_id = str(uuid.uuid4())
250 # Create database entry (settings_snapshot already captured above)
251 from ..database.models import ResearchHistory
252 from datetime import datetime, UTC
254 created_at = datetime.now(UTC).isoformat()
256 with get_user_db_session(username) as db_session:
257 # Create the database entry (required for tracking)
258 research_meta = {
259 "submission": {
260 "parent_research_id": data.get("parent_research_id"),
261 "question": data.get("question"),
262 "strategy": "contextual-followup",
263 },
264 }
266 research = ResearchHistory(
267 id=research_id,
268 query=research_params["query"],
269 mode="quick", # Use 'quick' not 'quick_summary'
270 status=ResearchStatus.IN_PROGRESS,
271 created_at=created_at,
272 progress_log=[{"time": created_at, "progress": 0}],
273 research_meta=research_meta,
274 )
275 db_session.add(research)
276 db_session.commit()
277 logger.info(
278 f"Created follow-up research entry with ID: {research_id}"
279 )
281 # Start the research process using the existing infrastructure
282 # Use quick_summary mode for follow-ups by default
283 logger.info(
284 f"Starting follow-up research for query of type: {type(research_params.get('query'))}"
285 )
287 # Get model and search settings from user's settings
288 model_provider = settings_snapshot.get("llm.provider", {}).get(
289 "value", "ollama"
290 )
291 # Normalize provider to lowercase canonical form
292 model_provider = normalize_provider(model_provider)
293 model = settings_snapshot.get("llm.model", {}).get("value", "")
294 search_engine = settings_snapshot.get("search.tool", {}).get(
295 "value", "searxng"
296 )
298 # Spawn the research thread. If the spawn fails, the
299 # ResearchHistory row committed above would otherwise be
300 # permanently orphaned with status=IN_PROGRESS. Catch any
301 # exception, flip the status to FAILED, and return a clear
302 # error — same contract as the queue processor's terminal-
303 # failure branch (#3481) and the direct-UI spawn-failure path.
304 try:
305 start_research_process(
306 research_id,
307 research_params["query"],
308 "quick", # Use 'quick' for quick summary mode
309 run_research_process,
310 username=username,
311 user_password=user_password, # gitleaks:allow
312 model_provider=model_provider, # Pass model provider
313 model=model, # Pass model name
314 search_engine=search_engine, # Pass search engine
315 custom_endpoint=custom_endpoint, # Pass custom endpoint if any
316 strategy="enhanced-contextual-followup", # Use enhanced contextual follow-up strategy
317 iterations=research_params["max_iterations"],
318 questions_per_iteration=research_params[
319 "questions_per_iteration"
320 ],
321 delegate_strategy=research_params.get(
322 "delegate_strategy", "source-based"
323 ),
324 research_context=research_params["research_context"],
325 parent_research_id=research_params[
326 "parent_research_id"
327 ], # Pass parent research ID
328 settings_snapshot=settings_snapshot,
329 )
330 except DuplicateResearchError:
331 # A live thread already owns this research_id. Do NOT delete
332 # the row or mark it FAILED — the row belongs to the live
333 # thread and mutating it would terminate the running
334 # research from the user's perspective. Same contract as
335 # research_routes.start_research's duplicate-thread branch.
336 logger.warning(
337 f"Duplicate live thread detected for follow-up "
338 f"{research_id}; leaving state intact"
339 )
340 return jsonify(
341 {
342 "success": False,
343 "error": "Research is already running.",
344 }
345 ), 409
346 except SystemAtCapacityError:
347 # System at concurrent-research capacity. Roll back the
348 # IN_PROGRESS row committed above and return 429.
349 logger.warning(
350 f"SystemAtCapacityError starting follow-up {research_id}"
351 )
352 try:
353 from ..database.session_context import get_user_db_session
354 from ..database.models import ResearchHistory
356 with get_user_db_session(username) as cleanup_session:
357 cleanup_session.query(ResearchHistory).filter_by(
358 id=research_id
359 ).delete()
360 cleanup_session.commit()
361 except Exception:
362 logger.exception(
363 "Cleanup after follow-up capacity reject raised"
364 )
365 return jsonify(
366 {
367 "success": False,
368 "error": "Server is at research capacity. Please retry shortly.",
369 }
370 ), 429
371 except Exception:
372 logger.exception(
373 f"Failed to spawn follow-up research thread for {research_id}"
374 )
375 try:
376 from ..database.session_context import get_user_db_session
377 from ..database.models import ResearchHistory
379 with get_user_db_session(username) as cleanup_session:
380 research_row = (
381 cleanup_session.query(ResearchHistory)
382 .filter_by(id=research_id)
383 .first()
384 )
385 if research_row: 385 ↛ 387line 385 didn't jump to line 387 because the condition on line 385 was always true
386 research_row.status = ResearchStatus.FAILED
387 cleanup_session.commit()
388 except Exception:
389 logger.exception("Cleanup after follow-up spawn failure raised")
390 return jsonify(
391 {
392 "success": False,
393 "error": "Failed to start follow-up research. Please try again.",
394 }
395 ), 500
397 return jsonify(
398 {
399 "success": True,
400 "research_id": research_id,
401 "message": "Follow-up research started",
402 }
403 )
405 except Exception:
406 logger.exception("Error starting follow-up")
407 return jsonify(
408 {"success": False, "error": "An internal error has occurred."}
409 ), 500