Coverage for src/local_deep_research/followup_research/routes.py: 88%
118 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-03 23:15 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-03 23:15 +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 ..security.decorators import require_json_body
14from ..web.auth.decorators import login_required
15from ..web.auth.password_utils import get_user_password
17# Create blueprint
18followup_bp = Blueprint("followup", __name__, url_prefix="/api/followup")
20# NOTE: Routes use session["username"] (not .get()) intentionally.
21# @login_required guarantees the key exists; direct access fails fast
22# if the decorator is ever removed.
25@followup_bp.route("/prepare", methods=["POST"])
26@login_required
27@require_json_body(error_format="success")
28def prepare_followup():
29 """
30 Prepare a follow-up research by loading parent context.
32 Request body:
33 {
34 "parent_research_id": "uuid",
35 "question": "follow-up question"
36 }
38 Returns:
39 {
40 "success": true,
41 "parent_summary": "...",
42 "available_sources": 10,
43 "suggested_strategy": "source-based"
44 }
45 """
46 try:
47 data = request.get_json()
48 parent_id = data.get("parent_research_id")
49 question = data.get("question")
51 if not parent_id or not question:
52 return jsonify(
53 {
54 "success": False,
55 "error": "Missing parent_research_id or question",
56 }
57 ), 400
59 # Get username from session
60 username = session["username"]
62 # Get settings snapshot to use for suggested strategy
63 from ..settings.manager import SettingsManager
64 from ..database.session_context import get_user_db_session
66 with get_user_db_session(username) as db_session:
67 settings_manager = SettingsManager(db_session=db_session)
68 settings_snapshot = settings_manager.get_all_settings()
70 # Get strategy from settings
71 strategy_from_settings = settings_snapshot.get(
72 "search.search_strategy", {}
73 ).get("value", "source-based")
75 # Initialize service
76 service = FollowUpResearchService(username=username)
78 # Load parent context
79 parent_data = service.load_parent_research(parent_id)
81 if not parent_data:
82 logger.warning("Parent research {} not found", parent_id)
83 return jsonify(
84 {"success": False, "error": "Parent research not found"}
85 ), 404
87 # Prepare response with parent context summary
88 response = {
89 "success": True,
90 "parent_summary": parent_data.get("query", ""),
91 "available_sources": len(parent_data.get("resources", [])),
92 "suggested_strategy": strategy_from_settings, # Use strategy from settings
93 "parent_research": {
94 "id": parent_id,
95 "query": parent_data.get("query", ""),
96 "sources_count": len(parent_data.get("resources", [])),
97 },
98 }
100 return jsonify(response)
102 except Exception:
103 logger.exception("Error preparing follow-up")
104 return jsonify(
105 {"success": False, "error": "An internal error has occurred."}
106 ), 500
109@followup_bp.route("/start", methods=["POST"])
110@login_required
111@require_json_body(error_format="success")
112def start_followup():
113 """
114 Start a follow-up research.
116 Request body:
117 {
118 "parent_research_id": "uuid",
119 "question": "follow-up question",
120 "strategy": "source-based", # optional
121 "max_iterations": 1, # optional
122 "questions_per_iteration": 3 # optional
123 }
125 Returns:
126 {
127 "success": true,
128 "research_id": "new-uuid",
129 "message": "Follow-up research started"
130 }
131 """
132 try:
133 from ..web.services.research_service import (
134 start_research_process,
135 run_research_process,
136 )
137 import uuid
139 data = request.get_json()
141 # Get username from session
142 username = session["username"]
144 # Get settings snapshot first to use database values
145 from ..settings.manager import SettingsManager
146 from ..database.session_context import get_user_db_session
148 with get_user_db_session(username) as db_session:
149 settings_manager = SettingsManager(db_session=db_session)
150 settings_snapshot = settings_manager.get_all_settings()
152 # Get strategy from settings snapshot, fallback to source-based if not set
153 strategy_from_settings = settings_snapshot.get(
154 "search.search_strategy", {}
155 ).get("value", "source-based")
157 # Get iterations and questions from settings snapshot
158 iterations_from_settings = settings_snapshot.get(
159 "search.iterations", {}
160 ).get("value", 1)
161 questions_from_settings = settings_snapshot.get(
162 "search.questions_per_iteration", {}
163 ).get("value", 3)
165 # Create follow-up request using settings values
166 followup_request = FollowUpRequest(
167 parent_research_id=data.get("parent_research_id"),
168 question=data.get("question"),
169 strategy=strategy_from_settings, # Use strategy from settings
170 max_iterations=iterations_from_settings, # Use iterations from settings
171 questions_per_iteration=questions_from_settings, # Use questions from settings
172 )
174 # Initialize service
175 service = FollowUpResearchService(username=username)
177 # Prepare research parameters
178 research_params = service.perform_followup(followup_request)
180 logger.info(f"Research params type: {type(research_params)}")
181 logger.info(
182 f"Research params keys: {research_params.keys() if isinstance(research_params, dict) else 'Not a dict'}"
183 )
184 logger.info(
185 f"Query value: {research_params.get('query') if isinstance(research_params, dict) else 'N/A'}"
186 )
187 logger.info(
188 f"Query type: {type(research_params.get('query')) if isinstance(research_params, dict) else 'N/A'}"
189 )
191 # Get user password for metrics database access.
192 # Uses the shared helper (password_utils) so every route checks the
193 # same three sources in the same order — avoids subtle divergence.
194 # Must check BEFORE creating ResearchHistory to avoid orphaned records.
195 user_password = get_user_password(username)
197 if not user_password:
198 from ..database.encrypted_db import db_manager
200 if db_manager.has_encryption:
201 logger.error(
202 f"No password available for user {username} with encrypted database - "
203 "cannot start follow-up research (session password expired or lost after server restart)"
204 )
205 # Use success/error keys to match followup API convention
206 # (the followup frontend checks data.success and data.error)
207 return jsonify(
208 {
209 "success": False,
210 "error": "Your session has expired. Please log out and log back in to start research.",
211 }
212 ), 401
213 logger.warning(
214 f"No password available for metrics access for user {username}"
215 )
217 # Generate new research ID
218 research_id = str(uuid.uuid4())
220 # Create database entry (settings_snapshot already captured above)
221 from ..database.models import ResearchHistory
222 from datetime import datetime, UTC
224 created_at = datetime.now(UTC).isoformat()
226 with get_user_db_session(username) as db_session:
227 # Create the database entry (required for tracking)
228 research_meta = {
229 "submission": {
230 "parent_research_id": data.get("parent_research_id"),
231 "question": data.get("question"),
232 "strategy": "contextual-followup",
233 },
234 }
236 research = ResearchHistory(
237 id=research_id,
238 query=research_params["query"],
239 mode="quick", # Use 'quick' not 'quick_summary'
240 status=ResearchStatus.IN_PROGRESS,
241 created_at=created_at,
242 progress_log=[{"time": created_at, "progress": 0}],
243 research_meta=research_meta,
244 )
245 db_session.add(research)
246 db_session.commit()
247 logger.info(
248 f"Created follow-up research entry with ID: {research_id}"
249 )
251 # Start the research process using the existing infrastructure
252 # Use quick_summary mode for follow-ups by default
253 logger.info(
254 f"Starting follow-up research for query of type: {type(research_params.get('query'))}"
255 )
257 # Get model and search settings from user's settings
258 model_provider = settings_snapshot.get("llm.provider", {}).get(
259 "value", "ollama"
260 )
261 # Normalize provider to lowercase canonical form
262 model_provider = normalize_provider(model_provider)
263 model = settings_snapshot.get("llm.model", {}).get("value", "")
264 search_engine = settings_snapshot.get("search.tool", {}).get(
265 "value", "searxng"
266 )
267 custom_endpoint = settings_snapshot.get(
268 "llm.openai_endpoint.url", {}
269 ).get("value")
271 # Spawn the research thread. If the spawn fails, the
272 # ResearchHistory row committed above would otherwise be
273 # permanently orphaned with status=IN_PROGRESS. Catch any
274 # exception, flip the status to FAILED, and return a clear
275 # error — same contract as the queue processor's terminal-
276 # failure branch (#3481) and the direct-UI spawn-failure path.
277 try:
278 start_research_process(
279 research_id,
280 research_params["query"],
281 "quick", # Use 'quick' for quick summary mode
282 run_research_process,
283 username=username,
284 user_password=user_password, # gitleaks:allow
285 model_provider=model_provider, # Pass model provider
286 model=model, # Pass model name
287 search_engine=search_engine, # Pass search engine
288 custom_endpoint=custom_endpoint, # Pass custom endpoint if any
289 strategy="enhanced-contextual-followup", # Use enhanced contextual follow-up strategy
290 iterations=research_params["max_iterations"],
291 questions_per_iteration=research_params[
292 "questions_per_iteration"
293 ],
294 delegate_strategy=research_params.get(
295 "delegate_strategy", "source-based"
296 ),
297 research_context=research_params["research_context"],
298 parent_research_id=research_params[
299 "parent_research_id"
300 ], # Pass parent research ID
301 settings_snapshot=settings_snapshot,
302 )
303 except DuplicateResearchError:
304 # A live thread already owns this research_id. Do NOT delete
305 # the row or mark it FAILED — the row belongs to the live
306 # thread and mutating it would terminate the running
307 # research from the user's perspective. Same contract as
308 # research_routes.start_research's duplicate-thread branch.
309 logger.warning(
310 f"Duplicate live thread detected for follow-up "
311 f"{research_id}; leaving state intact"
312 )
313 return jsonify(
314 {
315 "success": False,
316 "error": "Research is already running.",
317 }
318 ), 409
319 except SystemAtCapacityError:
320 # System at concurrent-research capacity. Roll back the
321 # IN_PROGRESS row committed above and return 429.
322 logger.warning(
323 f"SystemAtCapacityError starting follow-up {research_id}"
324 )
325 try:
326 from ..database.session_context import get_user_db_session
327 from ..database.models import ResearchHistory
329 with get_user_db_session(username) as cleanup_session:
330 cleanup_session.query(ResearchHistory).filter_by(
331 id=research_id
332 ).delete()
333 cleanup_session.commit()
334 except Exception:
335 logger.exception(
336 "Cleanup after follow-up capacity reject raised"
337 )
338 return jsonify(
339 {
340 "success": False,
341 "error": "Server is at research capacity. Please retry shortly.",
342 }
343 ), 429
344 except Exception:
345 logger.exception(
346 f"Failed to spawn follow-up research thread for {research_id}"
347 )
348 try:
349 from ..database.session_context import get_user_db_session
350 from ..database.models import ResearchHistory
352 with get_user_db_session(username) as cleanup_session:
353 research_row = (
354 cleanup_session.query(ResearchHistory)
355 .filter_by(id=research_id)
356 .first()
357 )
358 if research_row: 358 ↛ 360line 358 didn't jump to line 360 because the condition on line 358 was always true
359 research_row.status = ResearchStatus.FAILED
360 cleanup_session.commit()
361 except Exception:
362 logger.exception("Cleanup after follow-up spawn failure raised")
363 return jsonify(
364 {
365 "success": False,
366 "error": "Failed to start follow-up research. Please try again.",
367 }
368 ), 500
370 return jsonify(
371 {
372 "success": True,
373 "research_id": research_id,
374 "message": "Follow-up research started",
375 }
376 )
378 except Exception:
379 logger.exception("Error starting follow-up")
380 return jsonify(
381 {"success": False, "error": "An internal error has occurred."}
382 ), 500