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
« 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
7import json
9from loguru import logger
10from sqlalchemy import func
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
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
29from ...security import filter_research_metadata, strip_settings_snapshot
30from typing import Annotated
32# Create the router for the history routes
33router = APIRouter(prefix="/history", tags=["history"])
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.
39# resolve_report_path removed - reports are now stored in database
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 )
52@router.get("/api")
53def get_history(
54 request: Request, username: Annotated[str, Depends(require_auth)]
55):
56 """Get the research history JSON data"""
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
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 )
101 logger.debug(f"All research count: {len(results)}")
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 }
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 )
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]
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)
144 history.append(item)
146 # Format response to match what client expects
147 return {
148 "status": "success",
149 "items": history, # Use 'items' key as expected by client
150 }
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 )
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):
177 with get_user_db_session(username) as db_session:
178 research = (
179 db_session.query(ResearchHistory).filter_by(id=research_id).first()
180 )
182 if not research:
183 return _research_not_found(research_id)
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 }
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"] = []
222 return result
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"""
233 logger.debug(f"Details route accessed for research_id: {research_id}")
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}")
244 if not research:
245 logger.error(f"Research not found for id: {research_id}")
246 return _research_not_found(research_id)
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 )
267 # Get logs from the dedicated log database
268 logs = get_logs_for_research(research_id, username)
270 # Get strategy information
271 strategy_name = get_research_strategy(research_id, username=username)
273 # Get an atomic snapshot of active research state
274 snapshot = get_active_research_snapshot(research_id)
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"]
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 ]
287 # Add unique memory logs to our return list
288 logs.extend(unique_memory_logs)
290 # Sort logs by timestamp
291 logs.sort(key=lambda x: x["time"])
293 progress = (
294 snapshot["progress"]
295 if snapshot is not None
296 else (100 if research_data["status"] == ResearchStatus.COMPLETED else 0)
297 )
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 }
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 )
323 if not research:
324 return _research_not_found(research_id, message="Report not found")
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
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 )
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)
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 }
359 # Merge with stored metadata
360 enhanced_metadata.update(stored_metadata)
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 }
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 )
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 )
394 if not research:
395 return _research_not_found(research_id, message="Report not found")
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
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 )
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 )
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.
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 """
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))
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 )
457 if not research:
458 return _research_not_found(research_id)
460 # Retrieve logs from the database
461 logs = get_logs_for_research(research_id, username, limit=limit)
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")
476 return {"status": "success", "logs": logs}
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)
501 total_logs = get_total_logs_for_research(research_id, username)
503 return {"status": "success", "total_logs": total_logs}