Coverage for src/local_deep_research/web/routers/followup.py: 86%

187 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1""" 

2Flask routes for follow-up research functionality. 

3""" 

4 

5from fastapi import APIRouter, Depends, Request 

6from fastapi.responses import JSONResponse 

7from ..dependencies.auth import require_auth 

8from ..dependencies.threadpool import run_db_sync 

9 

10from loguru import logger 

11 

12from ...constants import ResearchStatus 

13from ...exceptions import DuplicateResearchError, SystemAtCapacityError 

14from ...llm.providers.base import normalize_provider 

15from ...followup_research.service import FollowUpResearchService 

16from ...followup_research.models import FollowUpRequest 

17from ...utilities.url_utils import is_safe_custom_llm_endpoint 

18from .notes import _log_value_preview 

19 

20from ..auth.password_utils import resolve_user_password 

21from typing import Annotated 

22 

23# Create the router 

24router = APIRouter(prefix="/api/followup", tags=["followup"]) 

25 

26# NOTE: Routes use username (not .get()) intentionally. 

27# Depends(require_auth) guarantees the key exists; direct access fails 

28# fast if the dependency is ever removed. 

29 

30 

31@router.post("/prepare") 

32async def prepare_followup( 

33 request: Request, username: Annotated[str, Depends(require_auth)] 

34): 

35 """ 

36 Prepare a follow-up research by loading parent context. 

37 

38 Request body: 

39 { 

40 "parent_research_id": "uuid", 

41 "question": "follow-up question" 

42 } 

43 

44 Returns: 

45 { 

46 "success": true, 

47 "parent_summary": "...", 

48 "available_sources": 10, 

49 "suggested_strategy": "source-based" 

50 } 

51 """ 

52 try: 

53 try: 

54 data = await request.json() 

55 except ValueError: 

56 # Match start_followup: a malformed body is a client error, not 

57 # a 500 with a logged stack trace. 

58 return JSONResponse( 

59 {"success": False, "error": "Request body must be valid JSON"}, 

60 status_code=400, 

61 ) 

62 if not isinstance(data, dict): 

63 # Flask's @require_json_body returned 400 for any non-dict body. 

64 # Without this, a VALID but non-object body (e.g. `[1,2]` or a 

65 # bare string) parses fine and then `data.get(...)` raises 

66 # AttributeError, which the outer handler turns into a 500 plus a 

67 # logged stack trace. The sibling routers kept the gate 

68 # (chat._json_object_body, notes._notes_json_body); follow-up is 

69 # the one that lost it. 

70 return JSONResponse( 

71 { 

72 "success": False, 

73 "error": "Request body must be a JSON object", 

74 }, 

75 status_code=400, 

76 ) 

77 parent_id = data.get("parent_research_id") 

78 question = data.get("question") 

79 

80 if not parent_id or not question: 

81 return JSONResponse( 

82 { 

83 "success": False, 

84 "error": "Missing parent_research_id or question", 

85 }, 

86 status_code=400, 

87 ) 

88 

89 from ...settings.manager import SettingsManager 

90 from ...database.session_context import get_user_db_session 

91 

92 def _load_sync(): 

93 """Sync work: settings snapshot + parent research load.""" 

94 with get_user_db_session(username) as db_session: 

95 settings_manager = SettingsManager(db_session=db_session) 

96 settings_snapshot = settings_manager.get_all_settings() 

97 

98 strategy_from_settings = settings_snapshot.get( 

99 "search.search_strategy", {} 

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

101 

102 service = FollowUpResearchService(username=username) 

103 parent_data = service.load_parent_research(parent_id) 

104 return strategy_from_settings, parent_data 

105 

106 strategy_from_settings, parent_data = await run_db_sync(_load_sync) 

107 

108 if not parent_data: 

109 # Parent research doesn't exist (wrong ID, deleted, or belongs 

110 # to another user whose DB we can't read). Return 404 so the 

111 # caller doesn't submit a follow-up against a ghost parent. 

112 # (#6938: parent_id is the raw, unchecked body value here.) 

113 logger.warning( 

114 "Parent research {} not found for user {}", 

115 _log_value_preview(parent_id), 

116 username, 

117 ) 

118 return JSONResponse( 

119 {"success": False, "error": "Parent research not found"}, 

120 status_code=404, 

121 ) 

122 

123 # Prepare response with parent context summary 

124 return { 

125 "success": True, 

126 "parent_summary": parent_data.get("query", ""), 

127 "available_sources": len(parent_data.get("resources", [])), 

128 "suggested_strategy": strategy_from_settings, # Use strategy from settings 

129 "parent_research": { 

130 "id": parent_id, 

131 "query": parent_data.get("query", ""), 

132 "sources_count": len(parent_data.get("resources", [])), 

133 }, 

134 } 

135 

136 except Exception: 

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

138 return JSONResponse( 

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

140 status_code=500, 

141 ) 

142 

143 

144@router.post("/start") 

145async def start_followup( 

146 request: Request, username: Annotated[str, Depends(require_auth)] 

147): 

148 """ 

149 Start a follow-up research. 

150 

151 Request body: 

152 { 

153 "parent_research_id": "uuid", 

154 "question": "follow-up question", 

155 "strategy": "source-based", # optional 

156 "max_iterations": 1, # optional 

157 "questions_per_iteration": 3 # optional 

158 } 

159 

160 Returns: 

161 { 

162 "success": true, 

163 "research_id": "new-uuid", 

164 "message": "Follow-up research started" 

165 } 

166 """ 

167 # Guard the body parse: the try/except lives in _start_followup_sync, so 

168 # without this a malformed body escapes this route entirely instead of 

169 # returning the 400 the Flask original (and prepare_followup) returns. 

170 try: 

171 data = await request.json() 

172 except ValueError: 

173 return JSONResponse( 

174 {"success": False, "error": "Request body must be valid JSON"}, 

175 status_code=400, 

176 ) 

177 if not isinstance(data, dict): 

178 # See the identical guard in prepare_followup above. 

179 return JSONResponse( 

180 {"success": False, "error": "Request body must be a JSON object"}, 

181 status_code=400, 

182 ) 

183 return await run_db_sync(_start_followup_sync, data, username) 

184 

185 

186def _start_followup_sync(data, username): 

187 admission_lock = None 

188 try: 

189 from ..services.research_service import ( 

190 start_research_process, 

191 run_research_process, 

192 clamp_user_max_concurrent, 

193 ) 

194 from ..routes.globals import reclaim_stale_user_active_research 

195 from ..research_state import get_user_research_start_lock 

196 from ...database.models import UserActiveResearch 

197 import threading 

198 import uuid 

199 

200 # Get username from session 

201 

202 # Get settings snapshot first to use database values 

203 from ...settings.manager import SettingsManager 

204 from ...database.session_context import get_user_db_session 

205 

206 with get_user_db_session(username) as db_session: 

207 settings_manager = SettingsManager(db_session=db_session) 

208 settings_snapshot = settings_manager.get_all_settings() 

209 

210 # Get strategy from settings snapshot, fallback to source-based if not set 

211 strategy_from_settings = settings_snapshot.get( 

212 "search.search_strategy", {} 

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

214 

215 # Get iterations and questions from settings snapshot 

216 iterations_from_settings = settings_snapshot.get( 

217 "search.iterations", {} 

218 ).get("value", 1) 

219 questions_from_settings = settings_snapshot.get( 

220 "search.questions_per_iteration", {} 

221 ).get("value", 3) 

222 

223 # Create follow-up request using settings values 

224 followup_request = FollowUpRequest( 

225 parent_research_id=data.get("parent_research_id"), 

226 question=data.get("question"), 

227 strategy=strategy_from_settings, # Use strategy from settings 

228 max_iterations=iterations_from_settings, # Use iterations from settings 

229 questions_per_iteration=questions_from_settings, # Use questions from settings 

230 ) 

231 

232 # Initialize service 

233 service = FollowUpResearchService(username=username) 

234 

235 # Resolve the user's password (needed for metrics DB access later) and 

236 # decide authentication FIRST. An expired encrypted-DB session is a 401, 

237 # and it must be settled BEFORE the parent-ownership check below so an 

238 # unauthenticated caller sees "session expired" (401), not the 

239 # authorization outcome "parent not found" (404). Auth precedes authz. 

240 user_password, session_expired = resolve_user_password(username) 

241 if session_expired: 

242 # success/error keys match the followup API convention (the 

243 # followup frontend checks data.success and data.error). 

244 return JSONResponse( 

245 { 

246 "success": False, 

247 "error": "Your session has expired. Please log out and log back in to start research.", 

248 }, 

249 status_code=401, 

250 ) 

251 

252 # Reject a follow-up naming a parent research the caller does not own, 

253 # mirroring /api/followup/prepare's 404 contract. Research ids are 

254 # per-user (a different physical encrypted DB per user), so a parent id 

255 # that isn't in the caller's DB is not theirs; without this a user could 

256 # spawn a follow-up referencing another user's research_id (the parent 

257 # context comes back empty, but a research thread would still start). 

258 parent_id = data.get("parent_research_id") 

259 if not parent_id or not service.load_parent_research(parent_id): 

260 return JSONResponse( 

261 {"success": False, "error": "Parent research not found"}, 

262 status_code=404, 

263 ) 

264 

265 # Prepare research parameters 

266 research_params = service.perform_followup(followup_request) 

267 

268 # (#6938: the query is raw request input and a wide params dict has 

269 # unbounded keys, so both go through the bounded preview instead of 

270 # eager f-strings.) 

271 params_keys = ( 

272 list(research_params.keys()) 

273 if isinstance(research_params, dict) 

274 else "Not a dict" 

275 ) 

276 query_value = ( 

277 research_params.get("query") 

278 if isinstance(research_params, dict) 

279 else "N/A" 

280 ) 

281 logger.info("Research params type: {}", type(research_params)) 

282 logger.info("Research params keys: {}", _log_value_preview(params_keys)) 

283 logger.info("Query value: {}", _log_value_preview(query_value)) 

284 logger.info("Query type: {}", type(query_value)) 

285 

286 # (user_password / session-expired 401 are resolved above, before the 

287 # parent-ownership check, so auth precedes authz.) 

288 

289 # Pre-flight: refuse to spawn a research thread (and create an 

290 # orphan ResearchHistory row) when llm.model is empty. Mirrors the 

291 # empty-model check in web/routers/research.py's start_research — 

292 # same contract: HTTP 400 with an actionable message before any DB 

293 # writes or thread spawning. (This router uses success/error 

294 # response keys rather than status/message, matching the followup 

295 # API convention used by the other returns in this function.) 

296 if not settings_snapshot.get("llm.model", {}).get("value"): 

297 logger.error( 

298 "Follow-up research blocked: llm.model is not configured" 

299 ) 

300 return JSONResponse( 

301 { 

302 "success": False, 

303 "error": "Model is required. Please configure a model in the settings.", 

304 }, 

305 status_code=400, 

306 ) 

307 

308 # SSRF pre-flight on the LLM endpoint: reject metadata / link-local 

309 # targets at the request boundary, before any DB row is written. 

310 # This is fail-fast defense-in-depth — the OpenAI-compatible provider's 

311 # assert_base_url_safe re-validates the same URL before the client is 

312 # built. Private IPs and localhost pass because local LLMs 

313 # (Ollama / LM Studio / vLLM) live there, including scheme-less 

314 # endpoints (the helper normalizes exactly as the provider does). 

315 custom_endpoint = settings_snapshot.get( 

316 "llm.openai_endpoint.url", {} 

317 ).get("value") 

318 if not is_safe_custom_llm_endpoint(custom_endpoint): 

319 return JSONResponse( 

320 { 

321 "success": False, 

322 "error": "Invalid custom endpoint URL", 

323 }, 

324 status_code=400, 

325 ) 

326 

327 # Per-user concurrency admission. Follow-ups previously enforced NO 

328 # per-user cap: only the global research semaphore gated them, so a 

329 # single authenticated user could fire many rapid 

330 # /api/followup/start calls and monopolize the entire global 

331 # research budget, starving other tenants with 429s. Route the 

332 # follow-up through the SAME admission path 

333 # research_routes.start_research uses -- reclaim dead-thread rows, 

334 # count the user's live researches, and reject at the per-user cap. 

335 # A UserActiveResearch row is created below (alongside the 

336 # ResearchHistory) so both entry points keep consistent accounting. 

337 max_concurrent_researches = clamp_user_max_concurrent( 

338 settings_snapshot.get("app.max_concurrent_researches", {}).get( 

339 "value", 3 

340 ) 

341 ) 

342 

343 # The helpers above reuse a thread-local session and may leave a read 

344 # transaction open. End it before taking the process-wide lock order 

345 # used by direct starts and queue replay: admission gate, then DB. 

346 with get_user_db_session(username) as ordering_session: 

347 ordering_session.rollback() 

348 

349 # Retain the same stable per-user gate from stale-row reconciliation 

350 # through durable claim, worker registration, and failure cleanup. 

351 # Otherwise this route can reclaim a direct start in its pre-register 

352 # window, or have its own new claim reclaimed by queue replay. 

353 pending_admission_lock = get_user_research_start_lock(username) 

354 pending_admission_lock.acquire() 

355 admission_lock = pending_admission_lock 

356 

357 # Reclaim stale rows + count active. Mirrors the try/except in 

358 # research_routes.start_research: if the check itself fails we fall 

359 # back to allowing the start (the global semaphore is still a 

360 # backstop) rather than hard-failing the request. 

361 try: 

362 with get_user_db_session(username) as admission_session: 

363 if reclaim_stale_user_active_research( 363 ↛ 366line 363 didn't jump to line 366 because the condition on line 363 was never true

364 admission_session, username, logger=logger 

365 ): 

366 admission_session.commit() 

367 active_count = ( 

368 admission_session.query(UserActiveResearch) 

369 .filter_by( 

370 username=username, 

371 status=ResearchStatus.IN_PROGRESS, 

372 ) 

373 .count() 

374 ) 

375 at_capacity = active_count >= max_concurrent_researches 

376 except Exception: 

377 logger.exception("Failed to check active follow-up researches") 

378 at_capacity = False 

379 

380 if at_capacity: 

381 logger.warning( 

382 "Follow-up research rejected: user {} at per-user " 

383 "concurrency cap ({}/{})", 

384 username, 

385 active_count, 

386 max_concurrent_researches, 

387 ) 

388 return JSONResponse( 

389 { 

390 "success": False, 

391 "error": "Server is at research capacity. Please retry shortly.", 

392 }, 

393 status_code=429, 

394 ) 

395 

396 # Generate new research ID 

397 research_id = str(uuid.uuid4()) 

398 

399 # Create database entry (settings_snapshot already captured above) 

400 from ...database.models import ResearchHistory 

401 from datetime import datetime, UTC 

402 

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

404 

405 with get_user_db_session(username) as db_session: 

406 # Create the database entry (required for tracking) 

407 research_meta = { 

408 "submission": { 

409 "parent_research_id": data.get("parent_research_id"), 

410 "question": data.get("question"), 

411 "strategy": "contextual-followup", 

412 }, 

413 # Persist the run's settings snapshot so view/export 

414 # (report_assembly_service) renders the same 

415 # report.uncited_sources_mode the generation-time render 

416 # used. Mirrors the main-form creation path; completion 

417 # merge back-fills when missing. 

418 "settings_snapshot": settings_snapshot, 

419 } 

420 

421 research = ResearchHistory( 

422 id=research_id, 

423 query=research_params["query"], 

424 mode="quick", # Use 'quick' not 'quick_summary' 

425 status=ResearchStatus.IN_PROGRESS, 

426 created_at=created_at, 

427 progress_log=[{"time": created_at, "progress": 0}], 

428 research_meta=research_meta, 

429 ) 

430 db_session.add(research) 

431 

432 # Record the active-research row in the SAME transaction so the 

433 # per-user cap accounting matches research_routes.start_research. 

434 # thread_id is the spawning (request) thread for now; the worker 

435 # thread id isn't known until start_research_process runs. 

436 active_record = UserActiveResearch( 

437 username=username, 

438 research_id=research_id, 

439 status=ResearchStatus.IN_PROGRESS, 

440 thread_id=str(threading.current_thread().ident), 

441 settings_snapshot=settings_snapshot, 

442 ) 

443 db_session.add(active_record) 

444 db_session.commit() 

445 logger.info( 

446 f"Created follow-up research entry with ID: {research_id}" 

447 ) 

448 

449 # Post-commit race recheck. Two concurrent submissions can both 

450 # pass the up-front admission check before either commits its 

451 # row. If we are now over the cap, roll back BOTH rows and 

452 # reject at 429 -- follow-ups have no queue fallback, so unlike 

453 # research_routes.start_research (which re-queues) we simply ask 

454 # the client to retry. 

455 # 

456 # The DETECTION query is wrapped so that a failure to *ask* never 

457 # blocks a legitimate start (fail-open is right there: we do not 

458 # know we are over capacity). The REMEDIATION below is 

459 # deliberately NOT inside that handler. It used to be, and a 

460 # failure in the rollback -- e.g. the commit raising under 

461 # SQLCipher/WAL contention -- was swallowed by the same broad 

462 # `except`, after which control fell through and started the 

463 # research anyway, returning success: True for a run whose 

464 # tracking rows had just been deleted. Once we KNOW we are over 

465 # capacity, failing to clean up must not become a decision to 

466 # proceed. 

467 over_capacity = False 

468 try: 

469 final_count = ( 

470 db_session.query(UserActiveResearch) 

471 .filter_by( 

472 username=username, 

473 status=ResearchStatus.IN_PROGRESS, 

474 ) 

475 .count() 

476 ) 

477 over_capacity = final_count > max_concurrent_researches 

478 if over_capacity: 

479 logger.warning( 

480 "Race detected on follow-up start for {}: " 

481 "{} > {}, rolling back and rejecting", 

482 username, 

483 final_count, 

484 max_concurrent_researches, 

485 ) 

486 except Exception: 

487 logger.warning("Could not recheck active follow-up count") 

488 

489 if over_capacity: 

490 # Best-effort rollback: if it fails we still reject, and the 

491 # orphaned IN_PROGRESS row is reclaimed by 

492 # reclaim_stale_user_active_research on this user's next 

493 # start (its thread never spawned, so it reads as dead). 

494 try: 

495 db_session.delete(active_record) 

496 db_session.query(ResearchHistory).filter_by( 

497 id=research_id 

498 ).delete() 

499 db_session.commit() 

500 except Exception: 

501 from ...database.session_context import safe_rollback 

502 

503 logger.exception( 

504 "Rollback of the over-capacity follow-up rows failed " 

505 "for {}; still rejecting the request", 

506 username, 

507 ) 

508 safe_rollback(db_session, "followup over-capacity rollback") 

509 return JSONResponse( 

510 { 

511 "success": False, 

512 "error": "Server is at research capacity. Please retry shortly.", 

513 }, 

514 status_code=429, 

515 ) 

516 

517 # Start the research process using the existing infrastructure 

518 # Use quick_summary mode for follow-ups by default 

519 logger.info( 

520 f"Starting follow-up research for query of type: {type(research_params.get('query'))}" 

521 ) 

522 

523 # Get model and search settings from user's settings 

524 model_provider = settings_snapshot.get("llm.provider", {}).get( 

525 "value", "OLLAMA" 

526 ) 

527 # Normalize provider to lowercase canonical form 

528 model_provider = normalize_provider(model_provider) 

529 model = settings_snapshot.get("llm.model", {}).get("value", "") 

530 search_engine = settings_snapshot.get("search.tool", {}).get( 

531 "value", "searxng" 

532 ) 

533 

534 # Spawn the research thread. If the spawn fails, the 

535 # ResearchHistory row committed above would otherwise be 

536 # permanently orphaned with status=IN_PROGRESS. Catch any 

537 # exception, flip the status to FAILED, and return a clear 

538 # error — same contract as the queue processor's terminal- 

539 # failure branch (#3481) and the direct-UI spawn-failure path. 

540 try: 

541 start_research_process( 

542 research_id, 

543 research_params["query"], 

544 "quick", # Use 'quick' for quick summary mode 

545 run_research_process, 

546 username=username, 

547 user_password=user_password, # gitleaks:allow 

548 model_provider=model_provider, # Pass model provider 

549 model=model, # Pass model name 

550 search_engine=search_engine, # Pass search engine 

551 # llm.openai_endpoint.url is only the client's base URL for 

552 # the openai_endpoint provider; don't label other providers 

553 # with it. 

554 custom_endpoint=( 

555 custom_endpoint 

556 if model_provider == "openai_endpoint" 

557 else None 

558 ), 

559 strategy="enhanced-contextual-followup", # Use enhanced contextual follow-up strategy 

560 iterations=research_params["max_iterations"], 

561 questions_per_iteration=research_params[ 

562 "questions_per_iteration" 

563 ], 

564 delegate_strategy=research_params.get( 

565 "delegate_strategy", "source-based" 

566 ), 

567 research_context=research_params["research_context"], 

568 parent_research_id=research_params[ 

569 "parent_research_id" 

570 ], # Pass parent research ID 

571 settings_snapshot=settings_snapshot, 

572 ) 

573 except DuplicateResearchError: 

574 # A live thread already owns this research_id. Do NOT delete 

575 # the row or mark it FAILED — the row belongs to the live 

576 # thread and mutating it would terminate the running 

577 # research from the user's perspective. Same contract as 

578 # research_routes.start_research's duplicate-thread branch. 

579 logger.warning( 

580 f"Duplicate live thread detected for follow-up " 

581 f"{research_id}; leaving state intact" 

582 ) 

583 return JSONResponse( 

584 { 

585 "success": False, 

586 "error": "Research is already running.", 

587 }, 

588 status_code=409, 

589 ) 

590 except SystemAtCapacityError: 

591 # System at concurrent-research capacity. Roll back the rows 

592 # committed above (UserActiveResearch + IN_PROGRESS history) 

593 # and return 429. 

594 logger.warning( 

595 f"SystemAtCapacityError starting follow-up {research_id}" 

596 ) 

597 try: 

598 from ...database.session_context import get_user_db_session 

599 from ...database.models import ( 

600 ResearchHistory, 

601 UserActiveResearch, 

602 ) 

603 

604 with get_user_db_session(username) as cleanup_session: 

605 stale_active = ( 

606 cleanup_session.query(UserActiveResearch) 

607 .filter_by(username=username, research_id=research_id) 

608 .first() 

609 ) 

610 if stale_active: 

611 cleanup_session.delete(stale_active) 

612 cleanup_session.query(ResearchHistory).filter_by( 

613 id=research_id 

614 ).delete() 

615 cleanup_session.commit() 

616 except Exception: 

617 logger.exception( 

618 "Cleanup after follow-up capacity reject raised" 

619 ) 

620 return JSONResponse( 

621 { 

622 "success": False, 

623 "error": "Server is at research capacity. Please retry shortly.", 

624 }, 

625 status_code=429, 

626 ) 

627 except Exception: 

628 logger.exception( 

629 f"Failed to spawn follow-up research thread for {research_id}" 

630 ) 

631 try: 

632 from ...database.session_context import get_user_db_session 

633 from ...database.models import ( 

634 ResearchHistory, 

635 UserActiveResearch, 

636 ) 

637 

638 with get_user_db_session(username) as cleanup_session: 

639 stale_active = ( 

640 cleanup_session.query(UserActiveResearch) 

641 .filter_by(username=username, research_id=research_id) 

642 .first() 

643 ) 

644 if stale_active: 

645 cleanup_session.delete(stale_active) 

646 research_row = ( 

647 cleanup_session.query(ResearchHistory) 

648 .filter_by(id=research_id) 

649 .first() 

650 ) 

651 if research_row: 

652 research_row.status = ResearchStatus.FAILED 

653 cleanup_session.commit() 

654 except Exception: 

655 logger.exception("Cleanup after follow-up spawn failure raised") 

656 return JSONResponse( 

657 content={ 

658 "success": False, 

659 "error": "Failed to start follow-up research. Please try again.", 

660 }, 

661 status_code=500, 

662 ) 

663 

664 return { 

665 "success": True, 

666 "research_id": research_id, 

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

668 } 

669 

670 except Exception: 

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

672 return JSONResponse( 

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

674 status_code=500, 

675 ) 

676 finally: 

677 if admission_lock is not None: 

678 admission_lock.release()