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
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-20 01:24 +0000
1import json
3from flask import Blueprint, jsonify, request, session
4from loguru import logger
5from sqlalchemy import func
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
27# Create a Blueprint for the history routes
28history_bp = Blueprint("history", __name__, url_prefix="/history")
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.
35# resolve_report_path removed - reports are now stored in database
38@history_bp.route("/")
39@login_required
40def history_page():
41 """Render the history page"""
42 return render_template_with_defaults("pages/history.html")
45@history_bp.route("/api", methods=["GET"])
46@login_required
47def get_history():
48 """Get the research history JSON data"""
49 username = session["username"]
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)
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 )
84 logger.debug(f"All research count: {len(results)}")
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 }
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 )
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]
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)
127 history.append(item)
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 }
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
148@history_bp.route("/status/<string:research_id>")
149@limiter.exempt
150@login_required
151def get_research_status(research_id):
152 username = session["username"]
154 with get_user_db_session(username) as db_session:
155 research = (
156 db_session.query(ResearchHistory).filter_by(id=research_id).first()
157 )
159 if not research:
160 return _research_not_found(research_id)
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 }
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"] = []
199 return jsonify(result)
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"""
207 logger.debug(f"Details route accessed for research_id: {research_id}")
209 username = session["username"]
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}")
220 if not research:
221 logger.error(f"Research not found for id: {research_id}")
222 return _research_not_found(research_id)
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
242 # Get logs from the dedicated log database
243 logs = get_logs_for_research(research_id)
245 # Get strategy information
246 strategy_name = get_research_strategy(research_id, username=username)
248 # Get an atomic snapshot of active research state
249 snapshot = get_active_research_snapshot(research_id)
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"]
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 ]
262 # Add unique memory logs to our return list
263 logs.extend(unique_memory_logs)
265 # Sort logs by timestamp
266 logs.sort(key=lambda x: x["time"])
268 progress = (
269 snapshot["progress"]
270 if snapshot is not None
271 else (100 if research_data["status"] == ResearchStatus.COMPLETED else 0)
272 )
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 )
289@history_bp.route("/report/<string:research_id>")
290@login_required
291def get_report(research_id):
292 from ..auth.decorators import current_user
294 username = current_user()
296 with get_user_db_session(username) as db_session:
297 research = (
298 db_session.query(ResearchHistory).filter_by(id=research_id).first()
299 )
301 if not research:
302 return _research_not_found(research_id, message="Report not found")
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 )
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 )
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)
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 }
338 # Merge with stored metadata
339 enhanced_metadata.update(stored_metadata)
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
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
367 username = current_user()
369 with get_user_db_session(username) as db_session:
370 research = (
371 db_session.query(ResearchHistory).filter_by(id=research_id).first()
372 )
374 if not research:
375 return _research_not_found(research_id, message="Report not found")
377 try:
378 from ..services.report_assembly_service import (
379 assemble_full_report,
380 )
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 )
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
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.
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"]
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))
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 )
424 if not research:
425 return _research_not_found(research_id)
427 logs = get_logs_for_research(research_id, limit=limit)
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")
440 return jsonify({"status": "success", "logs": logs})
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)
450 return jsonify({"status": "success", "total_logs": total_logs})