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
« prev ^ index » next coverage.py v7.12.0, created at 2026-01-11 00:51 +0000
1import json
2import traceback
4from flask import Blueprint, jsonify, make_response, session, request
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
19# Create a Blueprint for the history routes
20history_bp = Blueprint("history", __name__, url_prefix="/history")
23# resolve_report_path removed - reports are now stored in database
26@history_bp.route("/")
27@login_required
28def history_page():
29 """Render the history page"""
30 return render_template_with_defaults("pages/history.html")
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
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 )
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 )
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 }
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"] = {}
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
92 dt = parser.parse(item["created_at"])
93 item["created_at"] = dt.isoformat()
94 except Exception:
95 pass
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
102 dt = parser.parse(item["completed_at"])
103 item["completed_at"] = dt.isoformat()
104 except Exception:
105 pass
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
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}")
124 history.append(item)
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 }
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
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
173 with get_user_db_session(username) as db_session:
174 research = (
175 db_session.query(ResearchHistory).filter_by(id=research_id).first()
176 )
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
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 }
194 globals_dict = get_globals()
195 active_research = globals_dict["active_research"]
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"] = []
214 return jsonify(result)
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
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}")
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
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 )
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
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
263 # Get logs from the dedicated log database
264 logs = get_logs_for_research(research_id)
266 # Get strategy information
267 strategy_name = get_research_strategy(research_id)
269 globals_dict = get_globals()
270 active_research = globals_dict["active_research"]
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"]
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 ]
283 # Add unique memory logs to our return list
284 logs.extend(unique_memory_logs)
286 # Sort logs by timestamp
287 logs.sort(key=lambda x: x["time"])
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 )
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
312 username = current_user()
314 with get_user_db_session(username) as db_session:
315 research = (
316 db_session.query(ResearchHistory).filter_by(id=research_id).first()
317 )
319 if not research:
320 return jsonify(
321 {"status": "error", "message": "Report not found"}
322 ), 404
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 )
331 if not report_data:
332 return jsonify(
333 {"status": "error", "message": "Report content not found"}
334 ), 404
336 # Extract content and metadata
337 content = report_data.get("content", "")
338 stored_metadata = report_data.get("metadata", {})
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 }
349 # Merge with stored metadata
350 enhanced_metadata.update(stored_metadata)
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
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
376 username = current_user()
378 with get_user_db_session(username) as db_session:
379 research = (
380 db_session.query(ResearchHistory).filter_by(id=research_id).first()
381 )
383 if not research:
384 return jsonify(
385 {"status": "error", "message": "Report not found"}
386 ), 404
388 try:
389 # Get report using storage abstraction
390 storage = get_report_storage(session=db_session)
391 content = storage.get_report(research_id, username)
393 if not content:
394 return jsonify(
395 {"status": "error", "message": "Report content not found"}
396 ), 404
398 return jsonify({"status": "success", "content": content})
399 except Exception:
400 return jsonify(
401 {"status": "error", "message": "Failed to retrieve report"}
402 ), 500
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
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 )
419 if not research:
420 return jsonify(
421 {"status": "error", "message": "Research not found"}
422 ), 404
424 # Retrieve logs from the database
425 logs = get_logs_for_research(research_id)
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)
437 return jsonify({"status": "success", "logs": formatted_logs})
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)
447 return jsonify({"status": "success", "total_logs": total_logs})