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

175 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +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 

6from ..utils.pagination import OFFSET_LIMIT_ERROR, parse_bounded_offset 

7 

8import json 

9 

10from loguru import logger 

11from .notes import _log_value_preview 

12from sqlalchemy import func 

13 

14from ...constants import ( 

15 HISTORY_LOGS_DEFAULT_LIMIT, 

16 HISTORY_LOGS_HARD_CAP, 

17 ResearchStatus, 

18) 

19from ...database.models import ResearchHistory 

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

21from ...database.session_context import get_user_db_session 

22 

23from ..models.database import ( 

24 get_logs_for_research, 

25 get_total_logs_for_research, 

26) 

27from ..research_state import get_active_research_snapshot 

28from ..services.research_service import get_research_strategy 

29from .research import _research_not_found 

30 

31from ...security import filter_research_metadata, strip_settings_snapshot 

32from typing import Annotated 

33 

34# Create the router for the history routes 

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

36 

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

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

39# fast if the dependency is ever removed. 

40 

41# resolve_report_path removed - reports are now stored in database 

42 

43 

44@router.get("/") 

45def history_page( 

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

47): 

48 """Render the history page""" 

49 return templates.TemplateResponse( 

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

51 ) 

52 

53 

54@router.get("/api") 

55def get_history( 

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

57): 

58 """Get the research history JSON data""" 

59 

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

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

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

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

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

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

66 try: 

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

68 except (TypeError, ValueError): 

69 limit = 200 

70 try: 

71 offset = parse_bounded_offset(request.query_params.get("offset")) 

72 except ValueError: 

73 return JSONResponse({"error": OFFSET_LIMIT_ERROR}, status_code=400) 

74 

75 try: 

76 with get_user_db_session(username) as db_session: 

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

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

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

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

81 results = ( 

82 db_session.query( 

83 ResearchHistory.id, 

84 ResearchHistory.title, 

85 ResearchHistory.query, 

86 ResearchHistory.mode, 

87 ResearchHistory.status, 

88 ResearchHistory.created_at, 

89 ResearchHistory.completed_at, 

90 ResearchHistory.duration_seconds, 

91 ResearchHistory.research_meta, 

92 ResearchHistory.chat_session_id, 

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

94 ) 

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

96 .group_by(ResearchHistory.id) 

97 .order_by(ResearchHistory.created_at.desc()) 

98 .limit(limit) 

99 .offset(offset) 

100 .all() 

101 ) 

102 

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

104 

105 # Convert to list of dicts 

106 history = [] 

107 for research in results: 

108 item = { 

109 "id": research.id, 

110 "title": research.title, 

111 "query": research.query, 

112 "mode": research.mode, 

113 "status": research.status, 

114 "created_at": research.created_at, 

115 "completed_at": research.completed_at, 

116 "duration_seconds": research.duration_seconds, 

117 "document_count": research.document_count, 

118 } 

119 

120 item["metadata"] = filter_research_metadata( 

121 research.research_meta 

122 ) 

123 if research.chat_session_id is not None: 

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

125 research.chat_session_id 

126 ) 

127 

128 # Recalculate duration if null but both timestamps exist 

129 if ( 

130 item["duration_seconds"] is None 

131 and item["created_at"] 

132 and item["completed_at"] 

133 ): 

134 try: 

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

136 

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

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

139 item["duration_seconds"] = int( 

140 (end_time - start_time).total_seconds() 

141 ) 

142 except Exception: 

143 logger.warning("Error recalculating duration") 

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

145 

146 history.append(item) 

147 

148 # Format response to match what client expects 

149 return { 

150 "status": "success", 

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

152 } 

153 

154 # CORS headers are handled by SecurityHeaders middleware 

155 except Exception: 

156 logger.exception("Error getting history") 

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

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

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

160 # in this file. 

161 return JSONResponse( 

162 { 

163 "status": "error", 

164 "items": [], 

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

166 }, 

167 status_code=500, 

168 ) 

169 

170 

171def _scrub_server_paths(value): 

172 """Recursively drop server-path keys from a parsed progress-log entry. 

173 

174 Rows written by <=v0.4.4 persisted the completion entry with 

175 ``metadata.report_path`` — the absolute server-side path of the 

176 generated report — inside the ``progress_log`` column. The raw 

177 column itself is never returned to the client; this scrub is 

178 defence in depth for the parsed entries returned as ``log``, in 

179 case a path-bearing key is ever nested back into the column. 

180 ``report_path`` is the only such key any writer has ever emitted. 

181 """ 

182 if isinstance(value, dict): 

183 return { 

184 k: _scrub_server_paths(v) 

185 for k, v in value.items() 

186 if k != "report_path" 

187 } 

188 if isinstance(value, list): 

189 return [_scrub_server_paths(item) for item in value] 

190 return value 

191 

192 

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

194@limiter.exempt 

195def get_research_status( 

196 request: Request, 

197 research_id, 

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

199): 

200 

201 with get_user_db_session(username) as db_session: 

202 research = ( 

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

204 ) 

205 

206 if not research: 

207 return _research_not_found(research_id) 

208 

209 # Extract attributes while session is active 

210 # to avoid DetachedInstanceError after the with block exits 

211 raw_progress_log = research.progress_log 

212 result = { 

213 "id": research.id, 

214 "query": research.query, 

215 "mode": research.mode, 

216 "status": research.status, 

217 "created_at": research.created_at, 

218 "completed_at": research.completed_at, 

219 # report_path deliberately omitted: it's the absolute 

220 # server-side path, which leaks directory layout — same rule 

221 # as /library/api/check-downloads and the history LIST 

222 # endpoint. progress_log is omitted for the same reason: 

223 # rows written by <=v0.4.4 embed report_path inside the 

224 # completion entry's metadata, and no client reads the raw 

225 # field — the sanitized parsed form is returned as "log". 

226 } 

227 

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

229 snapshot = get_active_research_snapshot(research_id) 

230 if snapshot is not None: 

231 result["progress"] = snapshot["progress"] 

232 result["log"] = snapshot["log"] 

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

234 result["progress"] = 100 

235 try: 

236 result["log"] = json.loads(raw_progress_log or "[]") 

237 except Exception: 

238 logger.warning( 

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

240 ) 

241 result["log"] = [] 

242 else: 

243 result["progress"] = 0 

244 try: 

245 result["log"] = json.loads(raw_progress_log or "[]") 

246 except Exception: 

247 logger.warning( 

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

249 ) 

250 result["log"] = [] 

251 

252 # Scrub once at egress so the invariant holds structurally for every 

253 # branch above, instead of being enumerated at each assignment site. 

254 # This also covers the snapshot branch's "log": its only writer 

255 # (the termination handler in research.py) has never nested 

256 # report_path into an entry's metadata, so scrubbing it is a no-op 

257 # today — and stays defence in depth if that ever changes. 

258 result["log"] = _scrub_server_paths(result["log"]) 

259 

260 return result 

261 

262 

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

264def get_research_details( 

265 request: Request, 

266 research_id, 

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

268): 

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

270 

271 logger.opt(lazy=True).debug( 

272 "Details route accessed for research_id: {}", 

273 lambda: _log_value_preview(research_id), 

274 ) 

275 

276 try: 

277 with get_user_db_session(username) as db_session: 

278 research = ( 

279 db_session.query(ResearchHistory) 

280 .filter_by(id=research_id) 

281 .first() 

282 ) 

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

284 

285 if not research: 

286 logger.error( 

287 f"Research not found for id: {_log_value_preview(research_id)}" 

288 ) 

289 return _research_not_found(research_id) 

290 

291 # Extract all needed attributes while session is active 

292 # to avoid DetachedInstanceError after the with block exits 

293 research_data = { 

294 "query": research.query, 

295 "mode": research.mode, 

296 "status": research.status, 

297 "created_at": research.created_at, 

298 "completed_at": research.completed_at, 

299 } 

300 except Exception: 

301 logger.exception("Database error") 

302 return JSONResponse( 

303 { 

304 "status": "error", 

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

306 }, 

307 status_code=500, 

308 ) 

309 

310 # Get logs from the dedicated log database 

311 logs = get_logs_for_research(research_id, username) 

312 

313 # Get strategy information 

314 strategy_name = get_research_strategy(research_id, username=username) 

315 

316 # Get an atomic snapshot of active research state 

317 snapshot = get_active_research_snapshot(research_id) 

318 

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

320 if snapshot is not None: 

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

322 memory_logs = snapshot["log"] 

323 

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

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

326 unique_memory_logs = [ 

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

328 ] 

329 

330 # Add unique memory logs to our return list 

331 logs.extend(unique_memory_logs) 

332 

333 # Sort logs by timestamp 

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

335 

336 progress = ( 

337 snapshot["progress"] 

338 if snapshot is not None 

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

340 ) 

341 

342 return { 

343 "research_id": research_id, 

344 "query": research_data["query"], 

345 "mode": research_data["mode"], 

346 "status": research_data["status"], 

347 "strategy": strategy_name, 

348 "progress": progress, 

349 "created_at": research_data["created_at"], 

350 "completed_at": research_data["completed_at"], 

351 "log": logs, 

352 } 

353 

354 

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

356def get_report( 

357 request: Request, 

358 research_id, 

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

360): 

361 with get_user_db_session(username) as db_session: 

362 research = ( 

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

364 ) 

365 

366 if not research: 

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

368 

369 try: 

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

371 # the legacy display shape (answer + Sources from 

372 # research_resources + Metrics from research_meta) on demand. 

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

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

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

376 from ..services.report_assembly_service import assemble_full_report 

377 

378 content = assemble_full_report(research, db_session) 

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

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

381 if content is None: 

382 return _research_not_found( 

383 research_id, message="Report content not found" 

384 ) 

385 

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

387 # before returning research_meta to the client — settings_snapshot 

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

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

390 # routes; all other metadata fields are preserved. 

391 stored_metadata = strip_settings_snapshot(research.research_meta) 

392 

393 # Create an enhanced metadata dictionary with database fields 

394 enhanced_metadata = { 

395 "query": research.query, 

396 "mode": research.mode, 

397 "created_at": research.created_at, 

398 "completed_at": research.completed_at, 

399 "duration": research.duration_seconds, 

400 } 

401 

402 # Merge with stored metadata 

403 enhanced_metadata.update(stored_metadata) 

404 

405 return { 

406 "status": "success", 

407 "content": content, 

408 "query": research.query, 

409 "mode": research.mode, 

410 "created_at": research.created_at, 

411 "completed_at": research.completed_at, 

412 "metadata": enhanced_metadata, 

413 } 

414 

415 except Exception: 

416 logger.exception( 

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

418 ) 

419 return JSONResponse( 

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

421 status_code=500, 

422 ) 

423 

424 

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

426def get_markdown( 

427 request: Request, 

428 research_id, 

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

430): 

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

432 with get_user_db_session(username) as db_session: 

433 research = ( 

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

435 ) 

436 

437 if not research: 

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

439 

440 try: 

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

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

443 from ..services.report_assembly_service import assemble_full_report 

444 

445 content = assemble_full_report(research, db_session) 

446 if content is None: 

447 return _research_not_found( 

448 research_id, message="Report content not found" 

449 ) 

450 

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

452 except Exception: 

453 logger.exception( 

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

455 research_id, 

456 ) 

457 return JSONResponse( 

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

459 status_code=500, 

460 ) 

461 

462 

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

464def get_research_logs( 

465 request: Request, 

466 research_id, 

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

468): 

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

470 

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

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

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

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

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

476 parsed a ~50 MB JSON response). 

477 """ 

478 

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

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

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

482 # stops accidental unbounded loads. 

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

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

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

486 try: 

487 limit = int( 

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

489 ) 

490 except (TypeError, ValueError): 

491 limit = HISTORY_LOGS_DEFAULT_LIMIT 

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

493 

494 # First check if the research exists 

495 with get_user_db_session(username) as db_session: 

496 research = ( 

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

498 ) 

499 

500 if not research: 

501 return _research_not_found(research_id) 

502 

503 # Retrieve logs from the database 

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

505 

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

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

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

509 # UNCOVERED: the test that exercised it, 

510 # test_logs_with_missing_fields_get_defaults, was removed with the Flask 

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

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

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

514 for log in logs: 

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

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

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

518 

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

520 

521 

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

523def get_log_count( 

524 request: Request, 

525 research_id, 

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

527): 

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

529 # Verify ownership before exposing the count. 

530 with get_user_db_session(username) as db_session: 

531 research = ( 

532 db_session.query(ResearchHistory.id) 

533 .filter_by(id=research_id) 

534 .first() 

535 ) 

536 if not research: 

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

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

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

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

541 # reader keeps working. 

542 return _research_not_found(research_id) 

543 

544 total_logs = get_total_logs_for_research(research_id, username) 

545 

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