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

1""" 

2Flask routes for follow-up research functionality. 

3""" 

4 

5from flask import Blueprint, request, jsonify, session 

6from loguru import logger 

7 

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 

17 

18# Create blueprint 

19followup_bp = Blueprint("followup", __name__, url_prefix="/api/followup") 

20 

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. 

24 

25 

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. 

32 

33 Request body: 

34 { 

35 "parent_research_id": "uuid", 

36 "question": "follow-up question" 

37 } 

38 

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") 

51 

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 

59 

60 # Get username from session 

61 username = session["username"] 

62 

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 

66 

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() 

70 

71 # Get strategy from settings 

72 strategy_from_settings = settings_snapshot.get( 

73 "search.search_strategy", {} 

74 ).get("value", "source-based") 

75 

76 # Initialize service 

77 service = FollowUpResearchService(username=username) 

78 

79 # Load parent context 

80 parent_data = service.load_parent_research(parent_id) 

81 

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 

87 

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 } 

100 

101 return jsonify(response) 

102 

103 except Exception: 

104 logger.exception("Error preparing follow-up") 

105 return jsonify( 

106 {"success": False, "error": "An internal error has occurred."} 

107 ), 500 

108 

109 

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. 

116 

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 } 

125 

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 

139 

140 data = request.get_json() 

141 

142 # Get username from session 

143 username = session["username"] 

144 

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 

148 

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() 

152 

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") 

157 

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) 

165 

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 ) 

174 

175 # Initialize service 

176 service = FollowUpResearchService(username=username) 

177 

178 # Prepare research parameters 

179 research_params = service.perform_followup(followup_request) 

180 

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 ) 

191 

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) 

197 

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 

207 

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 

225 

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 ) 

246 

247 # Generate new research ID 

248 research_id = str(uuid.uuid4()) 

249 

250 # Create database entry (settings_snapshot already captured above) 

251 from ..database.models import ResearchHistory 

252 from datetime import datetime, UTC 

253 

254 created_at = datetime.now(UTC).isoformat() 

255 

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 } 

265 

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 ) 

280 

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 ) 

286 

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 ) 

297 

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 

355 

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 

378 

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 

396 

397 return jsonify( 

398 { 

399 "success": True, 

400 "research_id": research_id, 

401 "message": "Follow-up research started", 

402 } 

403 ) 

404 

405 except Exception: 

406 logger.exception("Error starting follow-up") 

407 return jsonify( 

408 {"success": False, "error": "An internal error has occurred."} 

409 ), 500