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

199 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-01-11 00:51 +0000

1import json 

2import traceback 

3 

4from flask import Blueprint, jsonify, make_response, session, request 

5 

6from ...database.models import ResearchHistory 

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

8from ...database.session_context import get_user_db_session 

9from ..auth.decorators import login_required 

10from ..models.database import ( 

11 get_logs_for_research, 

12 get_total_logs_for_research, 

13) 

14from ..routes.globals import get_globals 

15from ..services.research_service import get_research_strategy 

16from ..utils.rate_limiter import limiter 

17from ..utils.templates import render_template_with_defaults 

18 

19# Create a Blueprint for the history routes 

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

21 

22 

23# resolve_report_path removed - reports are now stored in database 

24 

25 

26@history_bp.route("/") 

27@login_required 

28def history_page(): 

29 """Render the history page""" 

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

31 

32 

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

34@login_required 

35def get_history(): 

36 """Get the research history JSON data""" 

37 username = session.get("username") 

38 if not username: 38 ↛ 39line 38 didn't jump to line 39 because the condition on line 38 was never true

39 return jsonify({"status": "error", "message": "Not authenticated"}), 401 

40 

41 try: 

42 with get_user_db_session(username) as db_session: 

43 # Get all history records ordered by latest first 

44 results = ( 

45 db_session.query(ResearchHistory) 

46 .order_by(ResearchHistory.created_at.desc()) 

47 .all() 

48 ) 

49 

50 # Convert to list of dicts 

51 history = [] 

52 for research in results: 52 ↛ 54line 52 didn't jump to line 54 because the loop on line 52 never started

53 # Count documents in the library for this research 

54 doc_count = ( 

55 db_session.query(Document) 

56 .filter_by(research_id=research.id) 

57 .count() 

58 ) 

59 

60 item = { 

61 "id": research.id, 

62 "title": research.title, 

63 "query": research.query, 

64 "mode": research.mode, 

65 "status": research.status, 

66 "created_at": research.created_at, 

67 "completed_at": research.completed_at, 

68 "duration_seconds": research.duration_seconds, 

69 "report_path": research.report_path, 

70 "document_count": doc_count, # Add document count 

71 "research_meta": json.dumps(research.research_meta) 

72 if research.research_meta 

73 else "{}", 

74 "progress_log": json.dumps(research.progress_log) 

75 if research.progress_log 

76 else "[]", 

77 } 

78 

79 # Parse research_meta as metadata for the frontend 

80 try: 

81 metadata = json.loads(item["research_meta"]) 

82 item["metadata"] = metadata 

83 except: 

84 item["metadata"] = {} 

85 

86 # Ensure timestamps are in ISO format 

87 if item["created_at"] and "T" not in item["created_at"]: 

88 try: 

89 # Convert to ISO format if it's not already 

90 from dateutil import parser 

91 

92 dt = parser.parse(item["created_at"]) 

93 item["created_at"] = dt.isoformat() 

94 except Exception: 

95 pass 

96 

97 if item["completed_at"] and "T" not in item["completed_at"]: 

98 try: 

99 # Convert to ISO format if it's not already 

100 from dateutil import parser 

101 

102 dt = parser.parse(item["completed_at"]) 

103 item["completed_at"] = dt.isoformat() 

104 except Exception: 

105 pass 

106 

107 # Recalculate duration based on timestamps if it's null but both timestamps exist 

108 if ( 

109 item["duration_seconds"] is None 

110 and item["created_at"] 

111 and item["completed_at"] 

112 ): 

113 try: 

114 from dateutil import parser 

115 

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

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

118 item["duration_seconds"] = int( 

119 (end_time - start_time).total_seconds() 

120 ) 

121 except Exception as e: 

122 print(f"Error recalculating duration: {e!s}") 

123 

124 history.append(item) 

125 

126 # Format response to match what client expects 

127 response_data = { 

128 "status": "success", 

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

130 } 

131 

132 # Add CORS headers 

133 response = make_response(jsonify(response_data)) 

134 response.headers.add("Access-Control-Allow-Origin", "*") 

135 response.headers.add( 

136 "Access-Control-Allow-Headers", "Content-Type,Authorization" 

137 ) 

138 response.headers.add( 

139 "Access-Control-Allow-Methods", "GET,PUT,POST,DELETE,OPTIONS" 

140 ) 

141 return response 

142 except Exception as e: 

143 print(f"Error getting history: {e!s}") 

144 print(traceback.format_exc()) 

145 # Return empty array with CORS headers 

146 response = make_response( 

147 jsonify( 

148 { 

149 "status": "error", 

150 "items": [], 

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

152 } 

153 ) 

154 ) 

155 response.headers.add("Access-Control-Allow-Origin", "*") 

156 response.headers.add( 

157 "Access-Control-Allow-Headers", "Content-Type,Authorization" 

158 ) 

159 response.headers.add( 

160 "Access-Control-Allow-Methods", "GET,PUT,POST,DELETE,OPTIONS" 

161 ) 

162 return response 

163 

164 

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

166@limiter.exempt 

167@login_required 

168def get_research_status(research_id): 

169 username = session.get("username") 

170 if not username: 170 ↛ 171line 170 didn't jump to line 171 because the condition on line 170 was never true

171 return jsonify({"status": "error", "message": "Not authenticated"}), 401 

172 

173 with get_user_db_session(username) as db_session: 

174 research = ( 

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

176 ) 

177 

178 if not research: 178 ↛ 179line 178 didn't jump to line 179 because the condition on line 178 was never true

179 return jsonify( 

180 {"status": "error", "message": "Research not found"} 

181 ), 404 

182 

183 result = { 

184 "id": research.id, 

185 "query": research.query, 

186 "mode": research.mode, 

187 "status": research.status, 

188 "created_at": research.created_at, 

189 "completed_at": research.completed_at, 

190 "progress_log": research.progress_log, 

191 "report_path": research.report_path, 

192 } 

193 

194 globals_dict = get_globals() 

195 active_research = globals_dict["active_research"] 

196 

197 # Add progress information 

198 if research_id in active_research: 198 ↛ 201line 198 didn't jump to line 201 because the condition on line 198 was always true

199 result["progress"] = active_research[research_id]["progress"] 

200 result["log"] = active_research[research_id]["log"] 

201 elif result.get("status") == "completed": 

202 result["progress"] = 100 

203 try: 

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

205 except Exception: 

206 result["log"] = [] 

207 else: 

208 result["progress"] = 0 

209 try: 

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

211 except Exception: 

212 result["log"] = [] 

213 

214 return jsonify(result) 

215 

216 

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

218@login_required 

219def get_research_details(research_id): 

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

221 from loguru import logger 

222 

223 logger.info(f"Details route accessed for research_id: {research_id}") 

224 logger.info(f"Request headers: {dict(request.headers)}") 

225 logger.info(f"Request URL: {request.url}") 

226 

227 username = session.get("username") 

228 if not username: 228 ↛ 229line 228 didn't jump to line 229 because the condition on line 228 was never true

229 logger.error("No username in session") 

230 return jsonify({"status": "error", "message": "Not authenticated"}), 401 

231 

232 try: 

233 with get_user_db_session(username) as db_session: 

234 # Log all research IDs for this user 

235 all_research = db_session.query( 

236 ResearchHistory.id, ResearchHistory.query 

237 ).all() 

238 logger.info( 

239 f"All research for user {username}: {[(r.id, r.query[:30]) for r in all_research]}" 

240 ) 

241 

242 research = ( 

243 db_session.query(ResearchHistory) 

244 .filter_by(id=research_id) 

245 .first() 

246 ) 

247 logger.info(f"Research query result: {research}") 

248 except Exception: 

249 logger.exception("Database error") 

250 return jsonify( 

251 { 

252 "status": "error", 

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

254 } 

255 ), 500 

256 

257 if not research: 257 ↛ 258line 257 didn't jump to line 258 because the condition on line 257 was never true

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

259 return jsonify( 

260 {"status": "error", "message": "Research not found"} 

261 ), 404 

262 

263 # Get logs from the dedicated log database 

264 logs = get_logs_for_research(research_id) 

265 

266 # Get strategy information 

267 strategy_name = get_research_strategy(research_id) 

268 

269 globals_dict = get_globals() 

270 active_research = globals_dict["active_research"] 

271 

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

273 if research_id in active_research: 273 ↛ 289line 273 didn't jump to line 289 because the condition on line 273 was always true

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

275 memory_logs = active_research[research_id]["log"] 

276 

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

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

279 unique_memory_logs = [ 

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

281 ] 

282 

283 # Add unique memory logs to our return list 

284 logs.extend(unique_memory_logs) 

285 

286 # Sort logs by timestamp 

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

288 

289 return jsonify( 

290 { 

291 "research_id": research_id, 

292 "query": research.query, 

293 "mode": research.mode, 

294 "status": research.status, 

295 "strategy": strategy_name, 

296 "progress": active_research.get(research_id, {}).get( 

297 "progress", 100 if research.status == "completed" else 0 

298 ), 

299 "created_at": research.created_at, 

300 "completed_at": research.completed_at, 

301 "log": logs, 

302 } 

303 ) 

304 

305 

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

307@login_required 

308def get_report(research_id): 

309 from ...storage import get_report_storage 

310 from ..auth.decorators import current_user 

311 

312 username = current_user() 

313 

314 with get_user_db_session(username) as db_session: 

315 research = ( 

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

317 ) 

318 

319 if not research: 

320 return jsonify( 

321 {"status": "error", "message": "Report not found"} 

322 ), 404 

323 

324 try: 

325 # Get report using storage abstraction 

326 storage = get_report_storage(session=db_session) 

327 report_data = storage.get_report_with_metadata( 

328 research_id, username 

329 ) 

330 

331 if not report_data: 

332 return jsonify( 

333 {"status": "error", "message": "Report content not found"} 

334 ), 404 

335 

336 # Extract content and metadata 

337 content = report_data.get("content", "") 

338 stored_metadata = report_data.get("metadata", {}) 

339 

340 # Create an enhanced metadata dictionary with database fields 

341 enhanced_metadata = { 

342 "query": research.query, 

343 "mode": research.mode, 

344 "created_at": research.created_at, 

345 "completed_at": research.completed_at, 

346 "duration": research.duration_seconds, 

347 } 

348 

349 # Merge with stored metadata 

350 enhanced_metadata.update(stored_metadata) 

351 

352 return jsonify( 

353 { 

354 "status": "success", 

355 "content": content, 

356 "query": research.query, 

357 "mode": research.mode, 

358 "created_at": research.created_at, 

359 "completed_at": research.completed_at, 

360 "metadata": enhanced_metadata, 

361 } 

362 ) 

363 except Exception: 

364 return jsonify( 

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

366 ), 500 

367 

368 

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

370@login_required 

371def get_markdown(research_id): 

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

373 from ...storage import get_report_storage 

374 from ..auth.decorators import current_user 

375 

376 username = current_user() 

377 

378 with get_user_db_session(username) as db_session: 

379 research = ( 

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

381 ) 

382 

383 if not research: 

384 return jsonify( 

385 {"status": "error", "message": "Report not found"} 

386 ), 404 

387 

388 try: 

389 # Get report using storage abstraction 

390 storage = get_report_storage(session=db_session) 

391 content = storage.get_report(research_id, username) 

392 

393 if not content: 

394 return jsonify( 

395 {"status": "error", "message": "Report content not found"} 

396 ), 404 

397 

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

399 except Exception: 

400 return jsonify( 

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

402 ), 500 

403 

404 

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

406@login_required 

407def get_research_logs(research_id): 

408 """Get logs for a specific research ID""" 

409 username = session.get("username") 

410 if not username: 

411 return jsonify({"status": "error", "message": "Not authenticated"}), 401 

412 

413 # First check if the research exists 

414 with get_user_db_session(username) as db_session: 

415 research = ( 

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

417 ) 

418 

419 if not research: 

420 return jsonify( 

421 {"status": "error", "message": "Research not found"} 

422 ), 404 

423 

424 # Retrieve logs from the database 

425 logs = get_logs_for_research(research_id) 

426 

427 # Format logs correctly if needed 

428 formatted_logs = [] 

429 for log in logs: 

430 log_entry = log.copy() 

431 # Ensure each log has time, message, and type fields 

432 log_entry["time"] = log.get("time", "") 

433 log_entry["message"] = log.get("message", "No message") 

434 log_entry["type"] = log.get("type", "info") 

435 formatted_logs.append(log_entry) 

436 

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

438 

439 

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

441@login_required 

442def get_log_count(research_id): 

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

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

445 total_logs = get_total_logs_for_research(research_id) 

446 

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