Coverage for src/local_deep_research/web/routers/history.py: 100%

165 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +0000

1from fastapi import APIRouter, Depends, Request 

2from fastapi.responses import JSONResponse 

3from ..dependencies.auth import require_auth 

4from ..dependencies.rate_limit import limiter 

5from ..template_config import templates 

6 

7import json 

8 

9from loguru import logger 

10from sqlalchemy import func 

11 

12from ...constants import ( 

13 HISTORY_LOGS_DEFAULT_LIMIT, 

14 HISTORY_LOGS_HARD_CAP, 

15 ResearchStatus, 

16) 

17from ...database.models import ResearchHistory 

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

19from ...database.session_context import get_user_db_session 

20 

21from ..models.database import ( 

22 get_logs_for_research, 

23 get_total_logs_for_research, 

24) 

25from ..research_state import get_active_research_snapshot 

26from ..services.research_service import get_research_strategy 

27from .research import _research_not_found 

28 

29from ...security import filter_research_metadata, strip_settings_snapshot 

30from typing import Annotated 

31 

32# Create the router for the history routes 

33router = APIRouter(prefix="/history", tags=["history"]) 

34 

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

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

37# fast if the dependency is ever removed. 

38 

39# resolve_report_path removed - reports are now stored in database 

40 

41 

42@router.get("/") 

43def history_page( 

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

45): 

46 """Render the history page""" 

47 return templates.TemplateResponse( 

48 request=request, name="pages/history.html", context={"request": request} 

49 ) 

50 

51 

52@router.get("/api") 

53def get_history( 

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

55): 

56 """Get the research history JSON data""" 

57 

58 # Flask parsed these with `type=int`, which falls back to the default on 

59 # a non-integer instead of raising. Bare int() inside the outer try meant 

60 # `?limit=abc` produced a 500 rather than a 200 with defaults. The sibling 

61 # endpoint kept the guard — see the clamp in library.py's 

62 # get_research_documents, whose comment even points back here — so this 

63 # was the odd one out, not a deliberate change. 

64 try: 

65 limit = max(1, min(int(request.query_params.get("limit", 200)), 500)) 

66 except (TypeError, ValueError): 

67 limit = 200 

68 try: 

69 offset = max(0, int(request.query_params.get("offset", 0))) 

70 except (TypeError, ValueError): 

71 offset = 0 

72 

73 try: 

74 with get_user_db_session(username) as db_session: 

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

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

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

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

79 results = ( 

80 db_session.query( 

81 ResearchHistory.id, 

82 ResearchHistory.title, 

83 ResearchHistory.query, 

84 ResearchHistory.mode, 

85 ResearchHistory.status, 

86 ResearchHistory.created_at, 

87 ResearchHistory.completed_at, 

88 ResearchHistory.duration_seconds, 

89 ResearchHistory.research_meta, 

90 ResearchHistory.chat_session_id, 

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

92 ) 

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

94 .group_by(ResearchHistory.id) 

95 .order_by(ResearchHistory.created_at.desc()) 

96 .limit(limit) 

97 .offset(offset) 

98 .all() 

99 ) 

100 

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

102 

103 # Convert to list of dicts 

104 history = [] 

105 for research in results: 

106 item = { 

107 "id": research.id, 

108 "title": research.title, 

109 "query": research.query, 

110 "mode": research.mode, 

111 "status": research.status, 

112 "created_at": research.created_at, 

113 "completed_at": research.completed_at, 

114 "duration_seconds": research.duration_seconds, 

115 "document_count": research.document_count, 

116 } 

117 

118 item["metadata"] = filter_research_metadata( 

119 research.research_meta 

120 ) 

121 if research.chat_session_id is not None: 

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

123 research.chat_session_id 

124 ) 

125 

126 # Recalculate duration if null but both timestamps exist 

127 if ( 

128 item["duration_seconds"] is None 

129 and item["created_at"] 

130 and item["completed_at"] 

131 ): 

132 try: 

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

134 

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

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

137 item["duration_seconds"] = int( 

138 (end_time - start_time).total_seconds() 

139 ) 

140 except Exception: 

141 logger.warning("Error recalculating duration") 

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

143 

144 history.append(item) 

145 

146 # Format response to match what client expects 

147 return { 

148 "status": "success", 

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

150 } 

151 

152 # CORS headers are handled by SecurityHeaders middleware 

153 except Exception: 

154 logger.exception("Error getting history") 

155 # 500, not a bare dict: a bare return serializes as 200, so the 

156 # client's `response.ok` check passes and it renders an empty 

157 # history as if the user had none. Matches every other error path 

158 # in this file. 

159 return JSONResponse( 

160 { 

161 "status": "error", 

162 "items": [], 

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

164 }, 

165 status_code=500, 

166 ) 

167 

168 

169@router.get("/status/{research_id}") 

170@limiter.exempt 

171def get_research_status( 

172 request: Request, 

173 research_id, 

174 username: Annotated[str, Depends(require_auth)], 

175): 

176 

177 with get_user_db_session(username) as db_session: 

178 research = ( 

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

180 ) 

181 

182 if not research: 

183 return _research_not_found(research_id) 

184 

185 # Extract attributes while session is active 

186 # to avoid DetachedInstanceError after the with block exits 

187 result = { 

188 "id": research.id, 

189 "query": research.query, 

190 "mode": research.mode, 

191 "status": research.status, 

192 "created_at": research.created_at, 

193 "completed_at": research.completed_at, 

194 "progress_log": research.progress_log, 

195 "report_path": research.report_path, 

196 } 

197 

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

199 snapshot = get_active_research_snapshot(research_id) 

200 if snapshot is not None: 

201 result["progress"] = snapshot["progress"] 

202 result["log"] = snapshot["log"] 

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

204 result["progress"] = 100 

205 try: 

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

207 except Exception: 

208 logger.warning( 

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

210 ) 

211 result["log"] = [] 

212 else: 

213 result["progress"] = 0 

214 try: 

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

216 except Exception: 

217 logger.warning( 

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

219 ) 

220 result["log"] = [] 

221 

222 return result 

223 

224 

225@router.get("/details/{research_id}") 

226def get_research_details( 

227 request: Request, 

228 research_id, 

229 username: Annotated[str, Depends(require_auth)], 

230): 

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

232 

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

234 

235 try: 

236 with get_user_db_session(username) as db_session: 

237 research = ( 

238 db_session.query(ResearchHistory) 

239 .filter_by(id=research_id) 

240 .first() 

241 ) 

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

243 

244 if not research: 

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

246 return _research_not_found(research_id) 

247 

248 # Extract all needed attributes while session is active 

249 # to avoid DetachedInstanceError after the with block exits 

250 research_data = { 

251 "query": research.query, 

252 "mode": research.mode, 

253 "status": research.status, 

254 "created_at": research.created_at, 

255 "completed_at": research.completed_at, 

256 } 

257 except Exception: 

258 logger.exception("Database error") 

259 return JSONResponse( 

260 { 

261 "status": "error", 

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

263 }, 

264 status_code=500, 

265 ) 

266 

267 # Get logs from the dedicated log database 

268 logs = get_logs_for_research(research_id, username) 

269 

270 # Get strategy information 

271 strategy_name = get_research_strategy(research_id, username=username) 

272 

273 # Get an atomic snapshot of active research state 

274 snapshot = get_active_research_snapshot(research_id) 

275 

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

277 if snapshot is not None: 

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

279 memory_logs = snapshot["log"] 

280 

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

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

283 unique_memory_logs = [ 

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

285 ] 

286 

287 # Add unique memory logs to our return list 

288 logs.extend(unique_memory_logs) 

289 

290 # Sort logs by timestamp 

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

292 

293 progress = ( 

294 snapshot["progress"] 

295 if snapshot is not None 

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

297 ) 

298 

299 return { 

300 "research_id": research_id, 

301 "query": research_data["query"], 

302 "mode": research_data["mode"], 

303 "status": research_data["status"], 

304 "strategy": strategy_name, 

305 "progress": progress, 

306 "created_at": research_data["created_at"], 

307 "completed_at": research_data["completed_at"], 

308 "log": logs, 

309 } 

310 

311 

312@router.get("/report/{research_id}") 

313def get_report( 

314 request: Request, 

315 research_id, 

316 username: Annotated[str, Depends(require_auth)], 

317): 

318 with get_user_db_session(username) as db_session: 

319 research = ( 

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

321 ) 

322 

323 if not research: 

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

325 

326 try: 

327 # research.report_content holds the answer-only string; rebuild 

328 # the legacy display shape (answer + Sources from 

329 # research_resources + Metrics from research_meta) on demand. 

330 # Do NOT read storage.get_report*() here — those return the 

331 # answer-only body (notification-teaser shape) and would drop the 

332 # Sources / Research Metrics sections (#3665). 

333 from ..services.report_assembly_service import assemble_full_report 

334 

335 content = assemble_full_report(research, db_session) 

336 # Only None means "research not found" — guarded above. An empty 

337 # but found row returns "" and is a valid response. 

338 if content is None: 

339 return _research_not_found( 

340 research_id, message="Report content not found" 

341 ) 

342 

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

344 # before returning research_meta to the client — settings_snapshot 

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

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

347 # routes; all other metadata fields are preserved. 

348 stored_metadata = strip_settings_snapshot(research.research_meta) 

349 

350 # Create an enhanced metadata dictionary with database fields 

351 enhanced_metadata = { 

352 "query": research.query, 

353 "mode": research.mode, 

354 "created_at": research.created_at, 

355 "completed_at": research.completed_at, 

356 "duration": research.duration_seconds, 

357 } 

358 

359 # Merge with stored metadata 

360 enhanced_metadata.update(stored_metadata) 

361 

362 return { 

363 "status": "success", 

364 "content": content, 

365 "query": research.query, 

366 "mode": research.mode, 

367 "created_at": research.created_at, 

368 "completed_at": research.completed_at, 

369 "metadata": enhanced_metadata, 

370 } 

371 

372 except Exception: 

373 logger.exception( 

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

375 ) 

376 return JSONResponse( 

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

378 status_code=500, 

379 ) 

380 

381 

382@router.get("/markdown/{research_id}") 

383def get_markdown( 

384 request: Request, 

385 research_id, 

386 username: Annotated[str, Depends(require_auth)], 

387): 

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

389 with get_user_db_session(username) as db_session: 

390 research = ( 

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

392 ) 

393 

394 if not research: 

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

396 

397 try: 

398 # Rebuild the assembled legacy shape (answer + Sources + Metrics) 

399 # rather than the answer-only storage.get_report() body (#3665). 

400 from ..services.report_assembly_service import assemble_full_report 

401 

402 content = assemble_full_report(research, db_session) 

403 if content is None: 

404 return _research_not_found( 

405 research_id, message="Report content not found" 

406 ) 

407 

408 return {"status": "success", "content": content} 

409 except Exception: 

410 logger.exception( 

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

412 research_id, 

413 ) 

414 return JSONResponse( 

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

416 status_code=500, 

417 ) 

418 

419 

420@router.get("/logs/{research_id}") 

421def get_research_logs( 

422 request: Request, 

423 research_id, 

424 username: Annotated[str, Depends(require_auth)], 

425): 

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

427 

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

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

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

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

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

433 parsed a ~50 MB JSON response). 

434 """ 

435 

436 # Per-request cap. HISTORY_LOGS_DEFAULT_LIMIT (500) matches 

437 # MAX_LOG_ENTRIES in logpanel.js; the HISTORY_LOGS_HARD_CAP (5000) 

438 # ceiling lets explicit log-download flows still get more rows but 

439 # stops accidental unbounded loads. 

440 # Parse defensively: a non-numeric ?limit= must not 500 (Werkzeug's 

441 # request.args.get(type=int) silently fell back to the default; the 

442 # bare int() here would raise ValueError -> 500). 

443 try: 

444 limit = int( 

445 request.query_params.get("limit", str(HISTORY_LOGS_DEFAULT_LIMIT)) 

446 ) 

447 except (TypeError, ValueError): 

448 limit = HISTORY_LOGS_DEFAULT_LIMIT 

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

450 

451 # First check if the research exists 

452 with get_user_db_session(username) as db_session: 

453 research = ( 

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

455 ) 

456 

457 if not research: 

458 return _research_not_found(research_id) 

459 

460 # Retrieve logs from the database 

461 logs = get_logs_for_research(research_id, username, limit=limit) 

462 

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

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

465 # columns, so this backfill is belt-and-braces. It is currently 

466 # UNCOVERED: the test that exercised it, 

467 # test_logs_with_missing_fields_get_defaults, was removed with the Flask 

468 # suite and has no successor (0 hits in tests/). The contract it asserted 

469 # was: extra keys preserved, missing keys defaulted. In-place mutation is 

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

471 for log in logs: 

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

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

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

475 

476 return {"status": "success", "logs": logs} 

477 

478 

479@router.get("/log_count/{research_id}") 

480def get_log_count( 

481 request: Request, 

482 research_id, 

483 username: Annotated[str, Depends(require_auth)], 

484): 

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

486 # Verify ownership before exposing the count. 

487 with get_user_db_session(username) as db_session: 

488 research = ( 

489 db_session.query(ResearchHistory.id) 

490 .filter_by(id=research_id) 

491 .first() 

492 ) 

493 if not research: 

494 # Use the shared helper rather than an inline 404, matching the 

495 # two sibling call sites in this file (lines ~165 and ~226) and 

496 # main's #5386 fix. It emits status/error/message together, a 

497 # superset of both historical 404 shapes, so every frontend 

498 # reader keeps working. 

499 return _research_not_found(research_id) 

500 

501 total_logs = get_total_logs_for_research(research_id, username) 

502 

503 return {"status": "success", "total_logs": total_logs}