Coverage for src/local_deep_research/web/routes/history_routes.py: 99%

169 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-20 01:24 +0000

1import json 

2 

3from flask import Blueprint, jsonify, request, session 

4from loguru import logger 

5from sqlalchemy import func 

6 

7from ...constants import ( 

8 HISTORY_LOGS_DEFAULT_LIMIT, 

9 HISTORY_LOGS_HARD_CAP, 

10 ResearchStatus, 

11) 

12from ...database.models import ResearchHistory 

13from ...database.models.library import Document as Document 

14from ...database.session_context import get_user_db_session 

15from ..auth.decorators import login_required 

16from ..models.database import ( 

17 get_logs_for_research, 

18 get_total_logs_for_research, 

19) 

20from ..routes.globals import get_active_research_snapshot 

21from ..routes.research_routes import _research_not_found 

22from ..services.research_service import get_research_strategy 

23from ...security.rate_limiter import limiter 

24from ...security import filter_research_metadata, strip_settings_snapshot 

25from ..utils.templates import render_template_with_defaults 

26 

27# Create a Blueprint for the history routes 

28history_bp = Blueprint("history", __name__, url_prefix="/history") 

29 

30# NOTE: Routes use session["username"] (not .get()) intentionally. 

31# @login_required guarantees the key exists; direct access fails fast 

32# if the decorator is ever removed. 

33 

34 

35# resolve_report_path removed - reports are now stored in database 

36 

37 

38@history_bp.route("/") 

39@login_required 

40def history_page(): 

41 """Render the history page""" 

42 return render_template_with_defaults("pages/history.html") 

43 

44 

45@history_bp.route("/api", methods=["GET"]) 

46@login_required 

47def get_history(): 

48 """Get the research history JSON data""" 

49 username = session["username"] 

50 

51 try: 

52 limit = request.args.get("limit", 200, type=int) 

53 limit = max(1, min(limit, 500)) 

54 offset = request.args.get("offset", 0, type=int) 

55 offset = max(0, offset) 

56 

57 with get_user_db_session(username) as db_session: 

58 # Single query with JOIN to get history + document counts. 

59 # Select only the columns the response needs — never the 

60 # large ``report_content`` Text body — so listing history 

61 # doesn't pull every report into memory (#4560). 

62 results = ( 

63 db_session.query( 

64 ResearchHistory.id, 

65 ResearchHistory.title, 

66 ResearchHistory.query, 

67 ResearchHistory.mode, 

68 ResearchHistory.status, 

69 ResearchHistory.created_at, 

70 ResearchHistory.completed_at, 

71 ResearchHistory.duration_seconds, 

72 ResearchHistory.research_meta, 

73 ResearchHistory.chat_session_id, 

74 func.count(Document.id).label("document_count"), 

75 ) 

76 .outerjoin(Document, Document.research_id == ResearchHistory.id) 

77 .group_by(ResearchHistory.id) 

78 .order_by(ResearchHistory.created_at.desc()) 

79 .limit(limit) 

80 .offset(offset) 

81 .all() 

82 ) 

83 

84 logger.debug(f"All research count: {len(results)}") 

85 

86 # Convert to list of dicts 

87 history = [] 

88 for research in results: 

89 item = { 

90 "id": research.id, 

91 "title": research.title, 

92 "query": research.query, 

93 "mode": research.mode, 

94 "status": research.status, 

95 "created_at": research.created_at, 

96 "completed_at": research.completed_at, 

97 "duration_seconds": research.duration_seconds, 

98 "document_count": research.document_count, 

99 } 

100 

101 item["metadata"] = filter_research_metadata( 

102 research.research_meta 

103 ) 

104 if research.chat_session_id is not None: 104 ↛ 105line 104 didn't jump to line 105 because the condition on line 104 was never true

105 item["metadata"]["chat_session_id"] = ( 

106 research.chat_session_id 

107 ) 

108 

109 # Recalculate duration if null but both timestamps exist 

110 if ( 

111 item["duration_seconds"] is None 

112 and item["created_at"] 

113 and item["completed_at"] 

114 ): 

115 try: 

116 from dateutil import parser # type: ignore[import-untyped] 

117 

118 start_time = parser.parse(item["created_at"]) 

119 end_time = parser.parse(item["completed_at"]) 

120 item["duration_seconds"] = int( 

121 (end_time - start_time).total_seconds() 

122 ) 

123 except Exception: 

124 logger.warning("Error recalculating duration") 

125 logger.debug("Duration error details", exc_info=True) 

126 

127 history.append(item) 

128 

129 # Format response to match what client expects 

130 response_data = { 

131 "status": "success", 

132 "items": history, # Use 'items' key as expected by client 

133 } 

134 

135 # CORS headers are handled by SecurityHeaders middleware 

136 return jsonify(response_data) 

137 except Exception: 

138 logger.exception("Error getting history") 

139 return jsonify( 

140 { 

141 "status": "error", 

142 "items": [], 

143 "message": "Failed to retrieve history", 

144 } 

145 ), 500 

146 

147 

148@history_bp.route("/status/<string:research_id>") 

149@limiter.exempt 

150@login_required 

151def get_research_status(research_id): 

152 username = session["username"] 

153 

154 with get_user_db_session(username) as db_session: 

155 research = ( 

156 db_session.query(ResearchHistory).filter_by(id=research_id).first() 

157 ) 

158 

159 if not research: 

160 return _research_not_found(research_id) 

161 

162 # Extract attributes while session is active 

163 # to avoid DetachedInstanceError after the with block exits 

164 result = { 

165 "id": research.id, 

166 "query": research.query, 

167 "mode": research.mode, 

168 "status": research.status, 

169 "created_at": research.created_at, 

170 "completed_at": research.completed_at, 

171 "progress_log": research.progress_log, 

172 "report_path": research.report_path, 

173 } 

174 

175 # Add progress information from active research (atomic snapshot) 

176 snapshot = get_active_research_snapshot(research_id) 

177 if snapshot is not None: 

178 result["progress"] = snapshot["progress"] 

179 result["log"] = snapshot["log"] 

180 elif result.get("status") == ResearchStatus.COMPLETED: 

181 result["progress"] = 100 

182 try: 

183 result["log"] = json.loads(result.get("progress_log", "[]")) 

184 except Exception: 

185 logger.warning( 

186 "Error parsing progress_log for research {}", research_id 

187 ) 

188 result["log"] = [] 

189 else: 

190 result["progress"] = 0 

191 try: 

192 result["log"] = json.loads(result.get("progress_log", "[]")) 

193 except Exception: 

194 logger.warning( 

195 "Error parsing progress_log for research {}", research_id 

196 ) 

197 result["log"] = [] 

198 

199 return jsonify(result) 

200 

201 

202@history_bp.route("/details/<string:research_id>") 

203@login_required 

204def get_research_details(research_id): 

205 """Get detailed progress log for a specific research""" 

206 

207 logger.debug(f"Details route accessed for research_id: {research_id}") 

208 

209 username = session["username"] 

210 

211 try: 

212 with get_user_db_session(username) as db_session: 

213 research = ( 

214 db_session.query(ResearchHistory) 

215 .filter_by(id=research_id) 

216 .first() 

217 ) 

218 logger.debug(f"Research found: {research.id if research else None}") 

219 

220 if not research: 

221 logger.error(f"Research not found for id: {research_id}") 

222 return _research_not_found(research_id) 

223 

224 # Extract all needed attributes while session is active 

225 # to avoid DetachedInstanceError after the with block exits 

226 research_data = { 

227 "query": research.query, 

228 "mode": research.mode, 

229 "status": research.status, 

230 "created_at": research.created_at, 

231 "completed_at": research.completed_at, 

232 } 

233 except Exception: 

234 logger.exception("Database error") 

235 return jsonify( 

236 { 

237 "status": "error", 

238 "message": "An internal database error occurred.", 

239 } 

240 ), 500 

241 

242 # Get logs from the dedicated log database 

243 logs = get_logs_for_research(research_id) 

244 

245 # Get strategy information 

246 strategy_name = get_research_strategy(research_id, username=username) 

247 

248 # Get an atomic snapshot of active research state 

249 snapshot = get_active_research_snapshot(research_id) 

250 

251 # If this is an active research, merge with any in-memory logs 

252 if snapshot is not None: 

253 # Use the logs from memory temporarily until they're saved to the database 

254 memory_logs = snapshot["log"] 

255 

256 # Filter out logs that are already in the database by timestamp 

257 db_timestamps = {log["time"] for log in logs} 

258 unique_memory_logs = [ 

259 log for log in memory_logs if log["time"] not in db_timestamps 

260 ] 

261 

262 # Add unique memory logs to our return list 

263 logs.extend(unique_memory_logs) 

264 

265 # Sort logs by timestamp 

266 logs.sort(key=lambda x: x["time"]) 

267 

268 progress = ( 

269 snapshot["progress"] 

270 if snapshot is not None 

271 else (100 if research_data["status"] == ResearchStatus.COMPLETED else 0) 

272 ) 

273 

274 return jsonify( 

275 { 

276 "research_id": research_id, 

277 "query": research_data["query"], 

278 "mode": research_data["mode"], 

279 "status": research_data["status"], 

280 "strategy": strategy_name, 

281 "progress": progress, 

282 "created_at": research_data["created_at"], 

283 "completed_at": research_data["completed_at"], 

284 "log": logs, 

285 } 

286 ) 

287 

288 

289@history_bp.route("/report/<string:research_id>") 

290@login_required 

291def get_report(research_id): 

292 from ..auth.decorators import current_user 

293 

294 username = current_user() 

295 

296 with get_user_db_session(username) as db_session: 

297 research = ( 

298 db_session.query(ResearchHistory).filter_by(id=research_id).first() 

299 ) 

300 

301 if not research: 

302 return _research_not_found(research_id, message="Report not found") 

303 

304 try: 

305 # research.report_content holds the answer-only string; 

306 # the legacy display shape is reconstructed on demand by 

307 # appending Sources (from research_resources) and Metrics 

308 # (from research_meta). 

309 from ..services.report_assembly_service import ( 

310 assemble_full_report, 

311 ) 

312 

313 content = assemble_full_report(research, db_session) 

314 # Only None means "research not found" — the existence check 

315 # above already returns 404 for that. An empty-but-found row 

316 # (no body, no sources, no metrics) returns "" and is valid. 

317 if content is None: 

318 return _research_not_found( 

319 research_id, message="Report content not found" 

320 ) 

321 

322 # Strip settings_snapshot (API keys, tokens, base URLs, paths) 

323 # before returning research_meta to the client — settings_snapshot 

324 # is persisted in research_meta but must never reach the API 

325 # response. Matches the sibling /details and /api/research/<id> 

326 # routes; all other metadata fields are preserved. 

327 stored_metadata = strip_settings_snapshot(research.research_meta) 

328 

329 # Create an enhanced metadata dictionary with database fields 

330 enhanced_metadata = { 

331 "query": research.query, 

332 "mode": research.mode, 

333 "created_at": research.created_at, 

334 "completed_at": research.completed_at, 

335 "duration": research.duration_seconds, 

336 } 

337 

338 # Merge with stored metadata 

339 enhanced_metadata.update(stored_metadata) 

340 

341 return jsonify( 

342 { 

343 "status": "success", 

344 "content": content, 

345 "query": research.query, 

346 "mode": research.mode, 

347 "created_at": research.created_at, 

348 "completed_at": research.completed_at, 

349 "metadata": enhanced_metadata, 

350 } 

351 ) 

352 except Exception: 

353 logger.exception( 

354 "Failed to retrieve report for research {}", research_id 

355 ) 

356 return jsonify( 

357 {"status": "error", "message": "Failed to retrieve report"} 

358 ), 500 

359 

360 

361@history_bp.route("/markdown/<string:research_id>") 

362@login_required 

363def get_markdown(research_id): 

364 """Get markdown export for a specific research""" 

365 from ..auth.decorators import current_user 

366 

367 username = current_user() 

368 

369 with get_user_db_session(username) as db_session: 

370 research = ( 

371 db_session.query(ResearchHistory).filter_by(id=research_id).first() 

372 ) 

373 

374 if not research: 

375 return _research_not_found(research_id, message="Report not found") 

376 

377 try: 

378 from ..services.report_assembly_service import ( 

379 assemble_full_report, 

380 ) 

381 

382 content = assemble_full_report(research, db_session) 

383 if content is None: 

384 return _research_not_found( 

385 research_id, message="Report content not found" 

386 ) 

387 

388 return jsonify({"status": "success", "content": content}) 

389 except Exception: 

390 logger.exception( 

391 "Failed to retrieve markdown report for research {}", 

392 research_id, 

393 ) 

394 return jsonify( 

395 {"status": "error", "message": "Failed to retrieve report"} 

396 ), 500 

397 

398 

399@history_bp.route("/logs/<string:research_id>") 

400@login_required 

401def get_research_logs(research_id): 

402 """Get logs for a specific research ID. 

403 

404 Accepts ``?limit=N`` to bound the response size; the default matches 

405 the frontend's ``MAX_LOG_ENTRIES`` DOM cap. Clamped to 

406 ``[1, HISTORY_LOGS_HARD_CAP]`` so a client cannot force an unbounded 

407 load (a long langgraph run can persist thousands of rows; pre-cap 

408 the route allocated ~150 MB transient on the server and Firefox 

409 parsed a ~50 MB JSON response). 

410 """ 

411 username = session["username"] 

412 

413 limit = request.args.get( 

414 "limit", default=HISTORY_LOGS_DEFAULT_LIMIT, type=int 

415 ) 

416 limit = max(1, min(limit, HISTORY_LOGS_HARD_CAP)) 

417 

418 # First check if the research exists 

419 with get_user_db_session(username) as db_session: 

420 research = ( 

421 db_session.query(ResearchHistory).filter_by(id=research_id).first() 

422 ) 

423 

424 if not research: 

425 return _research_not_found(research_id) 

426 

427 logs = get_logs_for_research(research_id, limit=limit) 

428 

429 # Defensive backfill for any row missing the three frontend-required 

430 # fields. `get_logs_for_research` always sets these from ResearchLog 

431 # columns, but the defensive layer is covered by 

432 # test_logs_with_missing_fields_get_defaults (extra keys must be 

433 # preserved, missing keys must take a default). In-place mutation is 

434 # safe — the formatter returned a fresh list of fresh dicts. 

435 for log in logs: 

436 log.setdefault("time", "") 

437 log.setdefault("message", "No message") 

438 log.setdefault("type", "info") 

439 

440 return jsonify({"status": "success", "logs": logs}) 

441 

442 

443@history_bp.route("/log_count/<string:research_id>") 

444@login_required 

445def get_log_count(research_id): 

446 """Get the total number of logs for a specific research ID""" 

447 # Get the total number of logs for this research ID 

448 total_logs = get_total_logs_for_research(research_id) 

449 

450 return jsonify({"status": "success", "total_logs": total_logs})