Coverage for src/local_deep_research/database/models/research.py: 95%
135 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
1"""
2Core research models for tasks, queries, and results.
3"""
5import enum
7from sqlalchemy import (
8 JSON,
9 Column,
10 Enum,
11 Float,
12 ForeignKey,
13 Index,
14 Integer,
15 String,
16 Text,
17 text,
18)
19from sqlalchemy.orm import relationship
21from sqlalchemy_utc import UtcDateTime, utcnow
23from ...constants import ResearchStatus
24from .base import Base
27class ResearchTask(Base):
28 """
29 Main research tasks that users create.
30 This is the top-level object that contains all research activities.
31 """
33 __tablename__ = "research_tasks"
35 id = Column(Integer, primary_key=True)
36 title = Column(String(500), nullable=False)
37 description = Column(Text)
38 status = Column(
39 String(50), default="pending"
40 ) # pending, in_progress, completed, failed
41 priority = Column(Integer, default=0) # Higher number = higher priority
42 tags = Column(JSON) # List of tags for categorization
43 research_metadata = Column(JSON) # Flexible field for additional data
45 # Timestamps
46 created_at = Column(UtcDateTime, default=utcnow())
47 updated_at = Column(UtcDateTime, default=utcnow(), onupdate=utcnow())
48 started_at = Column(UtcDateTime)
49 completed_at = Column(UtcDateTime)
51 # Relationships
52 searches = relationship(
53 "SearchQuery",
54 back_populates="research_task",
55 cascade="all, delete-orphan",
56 )
57 results = relationship(
58 "SearchResult",
59 back_populates="research_task",
60 cascade="all, delete-orphan",
61 )
62 reports = relationship(
63 "Report", back_populates="research_task", cascade="all, delete-orphan"
64 )
66 def __repr__(self):
67 return f"<ResearchTask(title='{self.title}', status='{self.status}')>"
70class SearchQuery(Base):
71 """
72 Individual search queries executed as part of research tasks.
73 Tracks what was searched and when.
74 """
76 __tablename__ = "search_queries"
78 id = Column(Integer, primary_key=True)
79 research_task_id = Column(
80 Integer, ForeignKey("research_tasks.id", ondelete="CASCADE")
81 )
82 query = Column(Text, nullable=False)
83 search_engine = Column(String(50)) # google, bing, duckduckgo, etc.
84 search_type = Column(String(50)) # web, academic, news, etc.
85 parameters = Column(JSON) # Additional search parameters
87 # Status tracking
88 status = Column(
89 String(50), default="pending"
90 ) # pending, executing, completed, failed
91 error_message = Column(Text)
92 retry_count = Column(Integer, default=0)
94 # Timestamps
95 created_at = Column(UtcDateTime, default=utcnow())
96 executed_at = Column(UtcDateTime)
97 completed_at = Column(UtcDateTime)
99 # Relationships
100 research_task = relationship("ResearchTask", back_populates="searches")
101 results = relationship(
102 "SearchResult",
103 back_populates="search_query",
104 cascade="all, delete-orphan",
105 )
107 # Indexes for performance
108 __table_args__ = (
109 Index("idx_research_task_status", "research_task_id", "status"),
110 Index("idx_search_engine", "search_engine", "status"),
111 )
113 def __repr__(self):
114 return f"<SearchQuery(query='{self.query[:50]}...', status='{self.status}')>"
117class SearchResult(Base):
118 """
119 Individual search results from queries.
120 Stores both the initial result and any fetched content.
121 """
123 __tablename__ = "search_results"
125 id = Column(Integer, primary_key=True)
126 research_task_id = Column(
127 Integer, ForeignKey("research_tasks.id", ondelete="CASCADE")
128 )
129 search_query_id = Column(
130 Integer, ForeignKey("search_queries.id", ondelete="CASCADE")
131 )
133 # Basic result information
134 title = Column(String(500))
135 url = Column(Text, index=True) # Indexed for deduplication
136 snippet = Column(Text)
138 # Extended content
139 content = Column(Text) # Full content if fetched
140 content_type = Column(String(50)) # html, pdf, text, etc.
141 content_hash = Column(String(64)) # For deduplication
143 # Metadata
144 relevance_score = Column(Float) # Calculated relevance
145 position = Column(Integer) # Position in search results
146 domain = Column(String(255), index=True)
147 language = Column(String(10))
148 published_date = Column(UtcDateTime)
149 author = Column(String(255))
151 # Status tracking
152 fetch_status = Column(String(50)) # pending, fetched, failed, skipped
153 fetch_error = Column(Text)
155 # Timestamps
156 created_at = Column(UtcDateTime, default=utcnow())
157 fetched_at = Column(UtcDateTime)
159 # Relationships
160 research_task = relationship("ResearchTask", back_populates="results")
161 search_query = relationship("SearchQuery", back_populates="results")
163 # Indexes for performance
164 __table_args__ = (
165 Index("idx_task_relevance", "research_task_id", "relevance_score"),
166 Index("idx_content_hash", "content_hash"),
167 Index("idx_domain_task", "domain", "research_task_id"),
168 )
170 def __repr__(self):
171 return f"<SearchResult(title='{self.title[:50] if self.title else 'No title'}...', score={self.relevance_score})>"
174class ResearchMode(enum.Enum):
175 """Research modes available."""
177 QUICK = "quick"
178 DETAILED = "detailed"
181class ResearchResource(Base):
182 """Resources associated with research projects."""
184 __tablename__ = "research_resources"
186 id = Column(Integer, primary_key=True, autoincrement=True)
187 # Named Index() in __table_args__ below (NOT index=True on this
188 # column) so both the create_all path (fresh installs, test fixtures)
189 # and the migration path (0006:286-289) produce an identically-
190 # named index: ix_research_resources_research_id. Using index=True
191 # would give create_all an auto-named index that diverges from the
192 # migration's named one; that asymmetry previously meant fresh
193 # installs had no research_id index and ran full-table scans on
194 # 20+ call sites.
195 research_id = Column(
196 String(36),
197 ForeignKey("research_history.id", ondelete="CASCADE"),
198 nullable=False,
199 )
200 title = Column(Text)
201 url = Column(Text)
202 content_preview = Column(Text)
203 source_type = Column(Text)
204 resource_metadata = Column("metadata", JSON)
205 created_at = Column(String, nullable=False)
206 # `use_alter=True` breaks the model-level circular FK with Document
207 # (Document.resource_id → research_resources.id, ResearchResource.document_id
208 # → documents.id). Without it, every cold start emits an SAWarning from
209 # `Base.metadata.sorted_tables` because SQLAlchemy can't topologically
210 # order the two tables. With `use_alter=True`, this FK is emitted as a
211 # post-CREATE ALTER TABLE so both tables exist by the time the
212 # constraint is added. Migration 0005 already chose not to enforce
213 # this FK at the DB level on existing DBs (SQLite batch-alter
214 # limitation), so this only changes how `create_all()` emits the FK
215 # for fresh installs going forward.
216 document_id = Column(
217 String(36),
218 ForeignKey(
219 "documents.id",
220 ondelete="SET NULL",
221 use_alter=True,
222 name="fk_research_resources_document_id",
223 ),
224 nullable=True,
225 index=True,
226 )
228 # Relationships
229 research = relationship("ResearchHistory", back_populates="resources")
230 document = relationship("Document", foreign_keys=[document_id])
231 paper_appearance = relationship(
232 "PaperAppearance",
233 back_populates="resource",
234 uselist=False,
235 cascade="all, delete-orphan",
236 )
238 __table_args__ = (
239 Index("ix_research_resources_research_id", "research_id"),
240 )
242 def __repr__(self):
243 return f"<ResearchResource(title='{self.title}', url='{self.url}')>"
246class ResearchHistory(Base):
247 """
248 Research history table.
249 Tracks research sessions and their progress.
250 """
252 __tablename__ = "research_history"
254 # UUID as primary key
255 id = Column(String(36), primary_key=True)
256 # The search query.
257 query = Column(Text, nullable=False)
258 # The mode of research (e.g., 'quick_summary', 'detailed_report').
259 mode = Column(Text, nullable=False)
260 # Current status of the research.
261 status = Column(Text, nullable=False)
262 # The timestamp when the research started.
263 created_at = Column(Text, nullable=False)
264 # The timestamp when the research was completed.
265 completed_at = Column(Text)
266 # Duration of the research in seconds.
267 duration_seconds = Column(Integer)
268 # Path to the generated report.
269 report_path = Column(Text)
270 # Report content stored in database
271 report_content = Column(Text)
272 # Additional metadata about the research.
273 research_meta = Column(JSON)
274 # Latest progress log message.
275 progress_log = Column(JSON)
276 # Current progress of the research (as a percentage).
277 progress = Column(Integer)
278 # Title of the research report.
279 title = Column(Text)
281 # Optional link to chat session that triggered this research.
282 # Named Index() in __table_args__ below (NOT index=True on this
283 # column) so both the create_all path (fresh installs, test
284 # fixtures) and the migration path (in migration 0010) produce an
285 # identically-named index: ix_research_history_chat_session_id.
286 # The matching ResearchResource pattern above documents the same
287 # divergence risk in detail.
288 chat_session_id = Column(
289 String(36),
290 ForeignKey("chat_sessions.id", ondelete="SET NULL"),
291 nullable=True,
292 )
294 # Atomic counter for ChatService.add_progress_step's per-research
295 # sequence_number allocation (mirrors ChatSession.message_count).
296 step_count = Column(Integer, nullable=False, default=0, server_default="0")
298 # Relationships
299 resources = relationship(
300 "ResearchResource",
301 back_populates="research",
302 cascade="all, delete-orphan",
303 )
304 chat_session = relationship(
305 "ChatSession",
306 foreign_keys=[chat_session_id],
307 back_populates="researches",
308 )
309 chat_messages = relationship(
310 "ChatMessage",
311 back_populates="research",
312 passive_deletes=True,
313 )
314 progress_steps = relationship(
315 "ChatProgressStep",
316 back_populates="research",
317 passive_deletes=True,
318 )
320 __table_args__ = (
321 Index("ix_research_history_chat_session_id", "chat_session_id"),
322 # Partial unique index closing the SELECT-then-INSERT race in
323 # chat/routes.py::send_message — only one in-progress research
324 # per chat session, NULL chat_session_id rows unconstrained.
325 # Migration 0010 mirrors this index exactly.
326 Index(
327 "ux_research_history_chat_session_in_progress",
328 "chat_session_id",
329 unique=True,
330 sqlite_where=text(
331 "status = 'in_progress' AND chat_session_id IS NOT NULL"
332 ),
333 postgresql_where=text(
334 "status = 'in_progress' AND chat_session_id IS NOT NULL"
335 ),
336 ),
337 )
339 def __repr__(self):
340 return f"<ResearchHistory(query='{self.query[:50]}...', status={self.status})>"
343class Research(Base):
344 """
345 Modern research tracking with better type safety.
346 """
348 __tablename__ = "research"
350 id = Column(Integer, primary_key=True)
351 query = Column(String, nullable=False)
352 status = Column(
353 Enum(ResearchStatus), default=ResearchStatus.PENDING, nullable=False
354 )
355 mode = Column(
356 Enum(ResearchMode), default=ResearchMode.QUICK, nullable=False
357 )
358 created_at = Column(UtcDateTime, server_default=utcnow(), nullable=False)
359 updated_at = Column(
360 UtcDateTime, server_default=utcnow(), onupdate=utcnow(), nullable=False
361 )
362 progress = Column(Float, default=0.0, nullable=False)
363 start_time = Column(UtcDateTime, nullable=True)
364 end_time = Column(UtcDateTime, nullable=True)
365 error_message = Column(Text, nullable=True)
367 def __repr__(self):
368 return f"<Research(query='{self.query[:50]}...', status={self.status.value})>"
371class ResearchStrategy(Base):
372 """
373 Track which search strategy was used for each research.
374 """
376 __tablename__ = "research_strategies"
378 id = Column(Integer, primary_key=True)
379 # FK targets research_history.id (the live UUID-keyed table) rather than
380 # the dormant `research` table. save_research_strategy passes the
381 # research_history UUID, so the prior Integer FK to research.id raised
382 # FOREIGN KEY constraint failed on every commit once v1.6.0 turned on
383 # PRAGMA foreign_keys.
384 research_id = Column(
385 String(36),
386 ForeignKey("research_history.id", ondelete="CASCADE"),
387 nullable=False,
388 unique=True,
389 index=True,
390 )
391 strategy_name = Column(String(100), nullable=False, index=True)
392 created_at = Column(UtcDateTime, server_default=utcnow(), nullable=False)
394 def __repr__(self):
395 return f"<ResearchStrategy(research_id={self.research_id}, strategy={self.strategy_name})>"