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

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 ..security.decorators import require_json_body 

14from ..web.auth.decorators import login_required 

15from ..web.auth.password_utils import get_user_password 

16 

17# Create blueprint 

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

19 

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. 

23 

24 

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. 

31 

32 Request body: 

33 { 

34 "parent_research_id": "uuid", 

35 "question": "follow-up question" 

36 } 

37 

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

50 

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 

58 

59 # Get username from session 

60 username = session["username"] 

61 

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 

65 

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

69 

70 # Get strategy from settings 

71 strategy_from_settings = settings_snapshot.get( 

72 "search.search_strategy", {} 

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

74 

75 # Initialize service 

76 service = FollowUpResearchService(username=username) 

77 

78 # Load parent context 

79 parent_data = service.load_parent_research(parent_id) 

80 

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 

86 

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 } 

99 

100 return jsonify(response) 

101 

102 except Exception: 

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

104 return jsonify( 

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

106 ), 500 

107 

108 

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. 

115 

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 } 

124 

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 

138 

139 data = request.get_json() 

140 

141 # Get username from session 

142 username = session["username"] 

143 

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 

147 

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

151 

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

156 

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) 

164 

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 ) 

173 

174 # Initialize service 

175 service = FollowUpResearchService(username=username) 

176 

177 # Prepare research parameters 

178 research_params = service.perform_followup(followup_request) 

179 

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 ) 

190 

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) 

196 

197 if not user_password: 

198 from ..database.encrypted_db import db_manager 

199 

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 ) 

216 

217 # Generate new research ID 

218 research_id = str(uuid.uuid4()) 

219 

220 # Create database entry (settings_snapshot already captured above) 

221 from ..database.models import ResearchHistory 

222 from datetime import datetime, UTC 

223 

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

225 

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 } 

235 

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 ) 

250 

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 ) 

256 

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

270 

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 

328 

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 

351 

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 

369 

370 return jsonify( 

371 { 

372 "success": True, 

373 "research_id": research_id, 

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

375 } 

376 ) 

377 

378 except Exception: 

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

380 return jsonify( 

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

382 ), 500