Coverage for src/local_deep_research/storage/database.py: 96%
71 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-03 23:15 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-03 23:15 +0000
1"""Database-based report storage implementation."""
3from typing import Dict, Any, List, Optional
4from loguru import logger
5from sqlalchemy.orm import Session
7from .base import ReportStorage
8from ..database.models import ResearchHistory
11class DatabaseReportStorage(ReportStorage):
12 """Store reports in the database with caching support."""
14 def __init__(self, session: Session):
15 """Initialize database storage.
17 Args:
18 session: SQLAlchemy database session
19 """
20 self.session = session
22 def save_report(
23 self,
24 research_id: str,
25 content: str,
26 metadata: Optional[Dict[str, Any]] = None,
27 username: Optional[str] = None,
28 ) -> bool:
29 """Save report to database."""
30 try:
31 research = (
32 self.session.query(ResearchHistory)
33 .filter_by(id=research_id)
34 .first()
35 )
37 if not research:
38 logger.error(f"Research {research_id} not found")
39 return False
41 research.report_content = content # type: ignore[assignment]
43 if metadata:
44 if research.research_meta:
45 research.research_meta.update(metadata) # type: ignore[union-attr]
46 else:
47 research.research_meta = metadata # type: ignore[assignment]
49 self.session.commit()
50 logger.info(f"Saved report for research {research_id} to database")
51 return True
53 except Exception:
54 logger.exception("Error saving report to database")
55 self.session.rollback()
56 return False
58 def get_report(
59 self, research_id: str, username: Optional[str] = None
60 ) -> Optional[str]:
61 """Return raw ``report_content`` for a research row.
63 IMPORTANT: ``report_content`` is the answer body only — the
64 assembled "## Sources" / "## Research Metrics" sections live in
65 ``research_resources`` and the
66 per-research metrics tables and are stitched in by
67 ``web.services.report_assembly_service.assemble_full_report``.
69 Do **not** use this method for any user-facing display path.
70 Call ``assemble_full_report(research, db_session)`` instead so
71 legacy rows (which embed sources/metrics inline in
72 ``report_content``) and new rows render identically. Current
73 callers of this method use the truncated content for
74 notification teasers / summary previews where answer-only is
75 the desired shape.
76 """
77 try:
78 research = (
79 self.session.query(ResearchHistory)
80 .filter_by(id=research_id)
81 .first()
82 )
84 if not research or not research.report_content:
85 return None
87 return research.report_content # type: ignore[return-value]
89 except Exception:
90 logger.exception("Error getting report from database")
91 return None
93 def get_report_with_metadata(
94 self, research_id: str, username: Optional[str] = None
95 ) -> Optional[Dict[str, Any]]:
96 """Return ``report_content`` + research metadata for a row.
98 Same answer-only caveat as :meth:`get_report` — see that
99 docstring before using ``["content"]`` for any user-facing
100 rendering path; prefer ``assemble_full_report`` instead.
101 """
102 try:
103 research = (
104 self.session.query(ResearchHistory)
105 .filter_by(id=research_id)
106 .first()
107 )
109 if not research or not research.report_content:
110 return None
112 return {
113 "content": research.report_content,
114 "metadata": research.research_meta or {},
115 "query": research.query,
116 "mode": research.mode,
117 "created_at": research.created_at,
118 "completed_at": research.completed_at,
119 "duration_seconds": research.duration_seconds,
120 }
122 except Exception:
123 logger.exception("Error getting report with metadata")
124 return None
126 def list_reports(
127 self, username: Optional[str] = None
128 ) -> List[Dict[str, Any]]:
129 """List reports from database."""
130 try:
131 query = self.session.query(ResearchHistory).filter(
132 ResearchHistory.report_content.isnot(None)
133 )
134 results = query.all()
135 return [
136 {
137 "id": r.id,
138 "query": r.query,
139 "mode": r.mode,
140 "created_at": r.created_at,
141 "completed_at": r.completed_at,
142 }
143 for r in results
144 ]
145 except Exception:
146 logger.exception("Error listing reports from database")
147 return []
149 def delete_report(
150 self, research_id: str, username: Optional[str] = None
151 ) -> bool:
152 """Delete report from database."""
153 try:
154 research = (
155 self.session.query(ResearchHistory)
156 .filter_by(id=research_id)
157 .first()
158 )
160 if not research:
161 return False
163 research.report_content = None # type: ignore[assignment]
164 self.session.commit()
166 return True
168 except Exception:
169 logger.exception("Error deleting report")
170 self.session.rollback()
171 return False
173 def report_exists(
174 self, research_id: str, username: Optional[str] = None
175 ) -> bool:
176 """Check if report exists in database."""
177 try:
178 research = (
179 self.session.query(ResearchHistory)
180 .filter_by(id=research_id)
181 .first()
182 )
184 return research is not None and research.report_content is not None
186 except Exception:
187 logger.exception("Error checking if report exists")
188 return False