Coverage for src/local_deep_research/database/models/research.py: 94%

156 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1""" 

2Core research models for tasks, queries, and results. 

3""" 

4 

5import enum 

6import json 

7 

8from loguru import logger 

9from sqlalchemy import ( 

10 JSON, 

11 Column, 

12 Enum, 

13 Float, 

14 ForeignKey, 

15 Index, 

16 Integer, 

17 String, 

18 Text, 

19 text, 

20) 

21from sqlalchemy.orm import relationship 

22from sqlalchemy.sql import operators 

23from sqlalchemy.types import TypeDecorator 

24 

25from sqlalchemy_utc import UtcDateTime, utcnow 

26 

27from ...constants import ResearchStatus 

28from .base import Base 

29 

30 

31class LenientJSON(TypeDecorator): 

32 """Read malformed legacy JSON text as absent without changing writes. 

33 

34 Releases before v0.5 persisted this column as TEXT through 

35 ``json.dumps``, and the stock ``JSON`` result processor raises 

36 ``JSONDecodeError`` while such a row is being loaded whenever the stored 

37 text is not valid JSON (the empty string from an interrupted write, a 

38 truncated ``json.dumps`` output, a ``str(dict)`` repr). That raise 

39 happens during the query, above every handler ``try``, so 

40 ``GET /history/status/{research_id}`` answered 500 for the row instead of 

41 degrading to an empty log or metadata (#6533, #7017). A malformed 

42 ``research_meta`` value could also make one row take down the entire 

43 history list. 

44 

45 Reads therefore return ``None`` for an unparseable value — the absent 

46 value readers of both columns already handle — and the load can no 

47 longer fail. Writes are untouched: the stock ``JSON`` bind path still 

48 serializes Python objects, so the DDL and the stored format are 

49 unchanged and no migration is needed. 

50 """ 

51 

52 impl = JSON 

53 cache_ok = True 

54 

55 def coerce_compared_value(self, op, value): 

56 # TypeDecorator otherwise JSON-encodes a LIKE pattern as a JSON 

57 # string. Subscription lookups compare against the stored TEXT form, 

58 # so their pattern must be bound as an ordinary SQL string. Delegate 

59 # other operators to JSON to preserve its key/index coercion rules. 

60 if op in ( 60 ↛ 67line 60 didn't jump to line 67 because the condition on line 60 was always true

61 operators.like_op, 

62 operators.not_like_op, 

63 operators.ilike_op, 

64 operators.not_ilike_op, 

65 ): 

66 return String() 

67 return self.impl.coerce_compared_value(op, value) 

68 

69 def result_processor(self, dialect, coltype): 

70 def process(value): 

71 # Only TEXT carriers need decoding. A backend with a native 

72 # JSON type hands back an already-parsed object — list, dict, 

73 # or a scalar such as 5 / true / null — and must not be fed to 

74 # json.loads, which would reject the non-string scalars and 

75 # turn a perfectly valid value into None. LDR ships on 

76 # SQLite/SQLCipher only, so this is defensive, not load-bearing. 

77 if not isinstance(value, (str, bytes, bytearray)): 

78 return value 

79 try: 

80 return json.loads(value) 

81 except (TypeError, ValueError): 

82 logger.warning( 

83 "Unparseable JSON value in a research_history column; " 

84 "treating it as absent" 

85 ) 

86 return None 

87 

88 return process 

89 

90 

91class ResearchTask(Base): 

92 """ 

93 Main research tasks that users create. 

94 This is the top-level object that contains all research activities. 

95 """ 

96 

97 __tablename__ = "research_tasks" 

98 

99 id = Column(Integer, primary_key=True) 

100 title = Column(String(500), nullable=False) 

101 description = Column(Text) 

102 status = Column( 

103 String(50), default="pending" 

104 ) # pending, in_progress, completed, failed 

105 priority = Column(Integer, default=0) # Higher number = higher priority 

106 tags = Column(JSON) # List of tags for categorization 

107 research_metadata = Column(JSON) # Flexible field for additional data 

108 

109 # Timestamps 

110 created_at = Column(UtcDateTime, default=utcnow()) 

111 updated_at = Column(UtcDateTime, default=utcnow(), onupdate=utcnow()) 

112 started_at = Column(UtcDateTime) 

113 completed_at = Column(UtcDateTime) 

114 

115 # Relationships 

116 searches = relationship( 

117 "SearchQuery", 

118 back_populates="research_task", 

119 cascade="all, delete-orphan", 

120 ) 

121 results = relationship( 

122 "SearchResult", 

123 back_populates="research_task", 

124 cascade="all, delete-orphan", 

125 ) 

126 reports = relationship( 

127 "Report", back_populates="research_task", cascade="all, delete-orphan" 

128 ) 

129 

130 def __repr__(self): 

131 return f"<ResearchTask(title='{self.title}', status='{self.status}')>" 

132 

133 

134class SearchQuery(Base): 

135 """ 

136 Individual search queries executed as part of research tasks. 

137 Tracks what was searched and when. 

138 """ 

139 

140 __tablename__ = "search_queries" 

141 

142 id = Column(Integer, primary_key=True) 

143 research_task_id = Column( 

144 Integer, ForeignKey("research_tasks.id", ondelete="CASCADE") 

145 ) 

146 query = Column(Text, nullable=False) 

147 search_engine = Column(String(50)) # google, bing, duckduckgo, etc. 

148 search_type = Column(String(50)) # web, academic, news, etc. 

149 parameters = Column(JSON) # Additional search parameters 

150 

151 # Status tracking 

152 status = Column( 

153 String(50), default="pending" 

154 ) # pending, executing, completed, failed 

155 error_message = Column(Text) 

156 retry_count = Column(Integer, default=0) 

157 

158 # Timestamps 

159 created_at = Column(UtcDateTime, default=utcnow()) 

160 executed_at = Column(UtcDateTime) 

161 completed_at = Column(UtcDateTime) 

162 

163 # Relationships 

164 research_task = relationship("ResearchTask", back_populates="searches") 

165 results = relationship( 

166 "SearchResult", 

167 back_populates="search_query", 

168 cascade="all, delete-orphan", 

169 ) 

170 

171 # Indexes for performance 

172 __table_args__ = ( 

173 Index("idx_research_task_status", "research_task_id", "status"), 

174 Index("idx_search_engine", "search_engine", "status"), 

175 ) 

176 

177 def __repr__(self): 

178 return f"<SearchQuery(query='{self.query[:50]}...', status='{self.status}')>" 

179 

180 

181class SearchResult(Base): 

182 """ 

183 Individual search results from queries. 

184 Stores both the initial result and any fetched content. 

185 """ 

186 

187 __tablename__ = "search_results" 

188 

189 id = Column(Integer, primary_key=True) 

190 research_task_id = Column( 

191 Integer, ForeignKey("research_tasks.id", ondelete="CASCADE") 

192 ) 

193 search_query_id = Column( 

194 Integer, ForeignKey("search_queries.id", ondelete="CASCADE") 

195 ) 

196 

197 # Basic result information 

198 title = Column(String(500)) 

199 url = Column(Text, index=True) # Indexed for deduplication 

200 snippet = Column(Text) 

201 

202 # Extended content 

203 content = Column(Text) # Full content if fetched 

204 content_type = Column(String(50)) # html, pdf, text, etc. 

205 content_hash = Column(String(64)) # For deduplication 

206 

207 # Metadata 

208 relevance_score = Column(Float) # Calculated relevance 

209 position = Column(Integer) # Position in search results 

210 domain = Column(String(255), index=True) 

211 language = Column(String(10)) 

212 published_date = Column(UtcDateTime) 

213 author = Column(String(255)) 

214 

215 # Status tracking 

216 fetch_status = Column(String(50)) # pending, fetched, failed, skipped 

217 fetch_error = Column(Text) 

218 

219 # Timestamps 

220 created_at = Column(UtcDateTime, default=utcnow()) 

221 fetched_at = Column(UtcDateTime) 

222 

223 # Relationships 

224 research_task = relationship("ResearchTask", back_populates="results") 

225 search_query = relationship("SearchQuery", back_populates="results") 

226 

227 # Indexes for performance 

228 __table_args__ = ( 

229 Index("idx_task_relevance", "research_task_id", "relevance_score"), 

230 Index("idx_content_hash", "content_hash"), 

231 Index("idx_domain_task", "domain", "research_task_id"), 

232 ) 

233 

234 def __repr__(self): 

235 return f"<SearchResult(title='{self.title[:50] if self.title else 'No title'}...', score={self.relevance_score})>" 

236 

237 

238class ResearchMode(enum.Enum): 

239 """Research modes available.""" 

240 

241 QUICK = "quick" 

242 DETAILED = "detailed" 

243 

244 

245class ResearchResource(Base): 

246 """Resources associated with research projects.""" 

247 

248 __tablename__ = "research_resources" 

249 

250 id = Column(Integer, primary_key=True, autoincrement=True) 

251 # Named Index() in __table_args__ below (NOT index=True on this 

252 # column) so both the create_all path (fresh installs, test fixtures) 

253 # and the migration path (0006:286-289) produce an identically- 

254 # named index: ix_research_resources_research_id. Using index=True 

255 # would give create_all an auto-named index that diverges from the 

256 # migration's named one; that asymmetry previously meant fresh 

257 # installs had no research_id index and ran full-table scans on 

258 # 20+ call sites. 

259 research_id = Column( 

260 String(36), 

261 ForeignKey("research_history.id", ondelete="CASCADE"), 

262 nullable=False, 

263 ) 

264 title = Column(Text) 

265 url = Column(Text) 

266 content_preview = Column(Text) 

267 source_type = Column(Text) 

268 resource_metadata = Column("metadata", JSON) 

269 created_at = Column(String, nullable=False) 

270 # `use_alter=True` breaks the model-level circular FK with Document 

271 # (Document.resource_id → research_resources.id, ResearchResource.document_id 

272 # → documents.id). Without it, every cold start emits an SAWarning from 

273 # `Base.metadata.sorted_tables` because SQLAlchemy can't topologically 

274 # order the two tables. With `use_alter=True`, this FK is emitted as a 

275 # post-CREATE ALTER TABLE so both tables exist by the time the 

276 # constraint is added. Migration 0005 already chose not to enforce 

277 # this FK at the DB level on existing DBs (SQLite batch-alter 

278 # limitation), so this only changes how `create_all()` emits the FK 

279 # for fresh installs going forward. 

280 document_id = Column( 

281 String(36), 

282 ForeignKey( 

283 "documents.id", 

284 ondelete="SET NULL", 

285 use_alter=True, 

286 name="fk_research_resources_document_id", 

287 ), 

288 nullable=True, 

289 index=True, 

290 ) 

291 

292 # Relationships 

293 research = relationship("ResearchHistory", back_populates="resources") 

294 document = relationship("Document", foreign_keys=[document_id]) 

295 paper_appearance = relationship( 

296 "PaperAppearance", 

297 back_populates="resource", 

298 uselist=False, 

299 cascade="all, delete-orphan", 

300 ) 

301 

302 __table_args__ = ( 

303 Index("ix_research_resources_research_id", "research_id"), 

304 ) 

305 

306 def __repr__(self): 

307 return f"<ResearchResource(title='{self.title}', url='{self.url}')>" 

308 

309 

310class ResearchHistory(Base): 

311 """ 

312 Research history table. 

313 Tracks research sessions and their progress. 

314 """ 

315 

316 __tablename__ = "research_history" 

317 

318 # UUID as primary key 

319 id = Column(String(36), primary_key=True) 

320 # The search query. 

321 query = Column(Text, nullable=False) 

322 # The mode of research (e.g., 'quick_summary', 'detailed_report'). 

323 mode = Column(Text, nullable=False) 

324 # Current status of the research. 

325 status = Column(Text, nullable=False) 

326 # The timestamp when the research started. 

327 created_at = Column(Text, nullable=False) 

328 # The timestamp when the research was completed. 

329 completed_at = Column(Text) 

330 # Duration of the research in seconds. 

331 duration_seconds = Column(Integer) 

332 # Path to the generated report. 

333 report_path = Column(Text) 

334 # Report content stored in database 

335 report_content = Column(Text) 

336 # Additional metadata about the research. 

337 research_meta = Column(LenientJSON) 

338 # Latest progress log message. 

339 progress_log = Column(LenientJSON) 

340 # Current progress of the research (as a percentage). 

341 progress = Column(Integer) 

342 # Title of the research report. 

343 title = Column(Text) 

344 

345 # Optional link to chat session that triggered this research. 

346 # Named Index() in __table_args__ below (NOT index=True on this 

347 # column) so both the create_all path (fresh installs, test 

348 # fixtures) and the migration path (in migration 0010) produce an 

349 # identically-named index: ix_research_history_chat_session_id. 

350 # The matching ResearchResource pattern above documents the same 

351 # divergence risk in detail. 

352 chat_session_id = Column( 

353 String(36), 

354 ForeignKey("chat_sessions.id", ondelete="SET NULL"), 

355 nullable=True, 

356 ) 

357 

358 # Atomic counter for ChatService.add_progress_step's per-research 

359 # sequence_number allocation (mirrors ChatSession.message_count). 

360 step_count = Column(Integer, nullable=False, default=0, server_default="0") 

361 

362 # Relationships 

363 resources = relationship( 

364 "ResearchResource", 

365 back_populates="research", 

366 cascade="all, delete-orphan", 

367 ) 

368 chat_session = relationship( 

369 "ChatSession", 

370 foreign_keys=[chat_session_id], 

371 back_populates="researches", 

372 ) 

373 chat_messages = relationship( 

374 "ChatMessage", 

375 back_populates="research", 

376 passive_deletes=True, 

377 ) 

378 progress_steps = relationship( 

379 "ChatProgressStep", 

380 back_populates="research", 

381 passive_deletes=True, 

382 ) 

383 

384 __table_args__ = ( 

385 Index("ix_research_history_chat_session_id", "chat_session_id"), 

386 # Partial unique index closing the SELECT-then-INSERT race in 

387 # chat/routes.py::send_message — only one in-progress research 

388 # per chat session, NULL chat_session_id rows unconstrained. 

389 # Migration 0010 mirrors this index exactly. 

390 Index( 

391 "ux_research_history_chat_session_in_progress", 

392 "chat_session_id", 

393 unique=True, 

394 sqlite_where=text( 

395 "status = 'in_progress' AND chat_session_id IS NOT NULL" 

396 ), 

397 postgresql_where=text( 

398 "status = 'in_progress' AND chat_session_id IS NOT NULL" 

399 ), 

400 ), 

401 ) 

402 

403 def __repr__(self): 

404 return f"<ResearchHistory(query='{self.query[:50]}...', status={self.status})>" 

405 

406 

407class Research(Base): 

408 """ 

409 Modern research tracking with better type safety. 

410 """ 

411 

412 __tablename__ = "research" 

413 

414 id = Column(Integer, primary_key=True) 

415 query = Column(String, nullable=False) 

416 status = Column( 

417 Enum(ResearchStatus), default=ResearchStatus.PENDING, nullable=False 

418 ) 

419 mode = Column( 

420 Enum(ResearchMode), default=ResearchMode.QUICK, nullable=False 

421 ) 

422 created_at = Column(UtcDateTime, server_default=utcnow(), nullable=False) 

423 updated_at = Column( 

424 UtcDateTime, server_default=utcnow(), onupdate=utcnow(), nullable=False 

425 ) 

426 progress = Column(Float, default=0.0, nullable=False) 

427 start_time = Column(UtcDateTime, nullable=True) 

428 end_time = Column(UtcDateTime, nullable=True) 

429 error_message = Column(Text, nullable=True) 

430 

431 def __repr__(self): 

432 return f"<Research(query='{self.query[:50]}...', status={self.status.value})>" 

433 

434 

435class ResearchStrategy(Base): 

436 """ 

437 Track which search strategy was used for each research. 

438 """ 

439 

440 __tablename__ = "research_strategies" 

441 

442 id = Column(Integer, primary_key=True) 

443 # FK targets research_history.id (the live UUID-keyed table) rather than 

444 # the dormant `research` table. save_research_strategy passes the 

445 # research_history UUID, so the prior Integer FK to research.id raised 

446 # FOREIGN KEY constraint failed on every commit once v1.6.0 turned on 

447 # PRAGMA foreign_keys. 

448 research_id = Column( 

449 String(36), 

450 ForeignKey("research_history.id", ondelete="CASCADE"), 

451 nullable=False, 

452 unique=True, 

453 index=True, 

454 ) 

455 strategy_name = Column(String(100), nullable=False, index=True) 

456 created_at = Column(UtcDateTime, server_default=utcnow(), nullable=False) 

457 

458 def __repr__(self): 

459 return f"<ResearchStrategy(research_id={self.research_id}, strategy={self.strategy_name})>"