Coverage for src/local_deep_research/news/core/card_storage.py: 97%

149 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-20 01:24 +0000

1""" 

2SQLAlchemy storage implementation for news cards. 

3 

4Note: This module aligns with the NewsCard SQLAlchemy model in database/models/news.py. 

5The NewsCard model has these relevant fields: 

6- id, title, summary, content, url 

7- source_name, source_type, source_id 

8- category, tags, card_type 

9- published_at, discovered_at 

10- is_read, read_at, is_saved, saved_at 

11- extra_data, subscription_id 

12""" 

13 

14from datetime import datetime, timedelta, timezone 

15from typing import List, Optional, Dict, Any 

16from sqlalchemy.orm import Session 

17from sqlalchemy import desc 

18from loguru import logger 

19 

20from .storage import CardStorage 

21from ...database.models.news import NewsCard 

22 

23 

24class SQLCardStorage(CardStorage): 

25 """SQLAlchemy implementation of card storage. 

26 

27 Maps between the card system's data model and the NewsCard database model. 

28 Some fields from the card system are stored in extra_data JSON field. 

29 """ 

30 

31 def __init__(self, session: Session): 

32 """Initialize with a database session from the user's encrypted database""" 

33 if not session: 

34 raise ValueError("Session is required for SQLCardStorage") 

35 self._session = session 

36 

37 @property 

38 def session(self): 

39 """Get database session""" 

40 return self._session 

41 

42 def create(self, data: Dict[str, Any]) -> str: 

43 """Create a new card. 

44 

45 Maps card system fields to NewsCard model: 

46 - topic → title 

47 - user_id, parent_card_id, created_from → stored in extra_data 

48 """ 

49 card_id = data.get("id") or self.generate_id() 

50 

51 # Extract source info if it's nested under "source"; otherwise 

52 # fall back to the flat source_type / source_id / created_from 

53 # fields. Note: `data.get("source", {})` returns {} when missing, 

54 # which is itself a dict — so check truthiness, not type, to 

55 # decide which branch applies. 

56 source_info = data.get("source") 

57 if isinstance(source_info, dict) and source_info: 

58 source_type = source_info.get("type") 

59 source_id = source_info.get("source_id") 

60 created_from = source_info.get("created_from") 

61 else: 

62 source_type = data.get("source_type") 

63 source_id = data.get("source_id") 

64 created_from = data.get("created_from") 

65 

66 # Map card_type enum properly 

67 card_type_str = data.get("card_type", data.get("type", "news")) 

68 

69 # Store extended fields in extra_data 

70 extra_data = data.get("extra_data", {}) or {} 

71 extra_data.update( 

72 { 

73 "user_id": data.get("user_id"), 

74 "parent_card_id": data.get("parent_card_id"), 

75 "created_from": created_from, 

76 "metadata": data.get("metadata", {}), 

77 "interaction": data.get("interaction", {}), 

78 } 

79 ) 

80 

81 with self.session as session: 

82 card = NewsCard( 

83 id=card_id, 

84 title=data.get("topic", data.get("title", "Untitled")), 

85 summary=data.get("summary"), 

86 content=data.get("content"), 

87 url=data.get("url", data.get("source_url")), 

88 source_name=data.get("source_name"), 

89 source_type=source_type, 

90 source_id=source_id, 

91 category=data.get("category"), 

92 tags=data.get("tags"), 

93 card_type=card_type_str, 

94 extra_data=extra_data, 

95 ) 

96 

97 session.add(card) 

98 session.commit() 

99 

100 user_id = data.get("user_id", "unknown") 

101 logger.info(f"Created card {card_id} for user {user_id}") 

102 return card_id 

103 

104 def get(self, id: str) -> Optional[Dict[str, Any]]: 

105 """Get a card by ID""" 

106 with self.session as session: 

107 card = session.query(NewsCard).filter_by(id=id).first() 

108 if not card: 

109 return None 

110 return self._card_to_dict(card) 

111 

112 def update(self, id: str, data: Dict[str, Any]) -> bool: 

113 """Update a card. 

114 

115 Maps card system fields to NewsCard model: 

116 - is_archived → stored in extra_data 

117 - is_pinned → is_saved 

118 - last_viewed → read_at (and sets is_read=True) 

119 """ 

120 with self.session as session: 

121 card = session.query(NewsCard).filter_by(id=id).first() 

122 if not card: 

123 return False 

124 

125 # Map is_pinned to is_saved 

126 if "is_pinned" in data: 

127 card.is_saved = data["is_pinned"] 

128 if data["is_pinned"]: 

129 card.saved_at = datetime.now(timezone.utc) 

130 

131 # Map last_viewed to read_at 

132 if "last_viewed" in data: 

133 card.is_read = True 

134 card.read_at = data["last_viewed"] 

135 

136 # Store is_archived and other custom fields in extra_data 

137 extra_data = card.extra_data or {} 

138 if "is_archived" in data: 

139 extra_data["is_archived"] = data["is_archived"] 

140 if "interaction" in data: 

141 extra_data["interaction"] = data["interaction"] 

142 card.extra_data = extra_data 

143 

144 session.commit() 

145 return True 

146 

147 def delete(self, id: str) -> bool: 

148 """Delete a card""" 

149 with self.session as session: 

150 card = session.query(NewsCard).filter_by(id=id).first() 

151 if not card: 

152 return False 

153 

154 session.delete(card) 

155 session.commit() 

156 return True 

157 

158 def list( 

159 self, 

160 filters: Optional[Dict[str, Any]] = None, 

161 limit: int = 100, 

162 offset: int = 0, 

163 ) -> List[Dict[str, Any]]: 

164 """List cards with optional filtering. 

165 

166 Supported filters: 

167 - user_id: Filter by user (stored in extra_data) 

168 - card_type: Filter by card type 

169 - is_archived: Filter by archived status (in extra_data) 

170 - is_pinned: Filter by pinned/saved status 

171 - category: Filter by category 

172 """ 

173 with self.session as session: 

174 query = session.query(NewsCard) 

175 

176 if filters: 

177 if "card_type" in filters: 

178 card_type_val = filters["card_type"] 

179 # Handle both string and list of types 

180 if isinstance(card_type_val, list): 

181 query = query.filter( 

182 NewsCard.card_type.in_(card_type_val) 

183 ) 

184 else: 

185 query = query.filter_by(card_type=card_type_val) 

186 if "is_pinned" in filters: 

187 query = query.filter_by(is_saved=filters["is_pinned"]) 

188 if "category" in filters: 

189 query = query.filter_by(category=filters["category"]) 

190 # Note: user_id and is_archived filtering would require 

191 # JSON querying which varies by database backend 

192 

193 # Order by discovery date (newest first) 

194 query = query.order_by(desc(NewsCard.discovered_at)) 

195 

196 cards = query.limit(limit).offset(offset).all() 

197 return [self._card_to_dict(card) for card in cards] 

198 

199 def get_recent( 

200 self, 

201 hours: int = 24, 

202 card_types: Optional[List[str]] = None, 

203 limit: int = 50, 

204 ) -> List[Dict[str, Any]]: 

205 """Get recent cards within the specified time window. 

206 

207 Args: 

208 hours: How many hours back to look (default 24) 

209 card_types: Optional list of card types to filter 

210 limit: Maximum number of cards to return 

211 

212 Returns: 

213 List of card dictionaries 

214 """ 

215 cutoff = datetime.now(timezone.utc) - timedelta(hours=hours) 

216 

217 with self.session as session: 

218 query = session.query(NewsCard).filter( 

219 NewsCard.discovered_at >= cutoff 

220 ) 

221 

222 if card_types: 

223 query = query.filter(NewsCard.card_type.in_(card_types)) 

224 

225 query = query.order_by(desc(NewsCard.discovered_at)) 

226 cards = query.limit(limit).all() 

227 

228 return [self._card_to_dict(card) for card in cards] 

229 

230 def _card_to_dict(self, card: NewsCard) -> Dict[str, Any]: 

231 """Convert a NewsCard model to the dictionary format expected by the card system. 

232 

233 Maps NewsCard model fields back to card system format: 

234 - title → topic 

235 - is_saved → is_pinned 

236 - extra_data fields → top-level fields 

237 """ 

238 extra_data: Dict[str, Any] = ( 

239 dict(card.extra_data) if card.extra_data else {} 

240 ) 

241 

242 return { 

243 "id": card.id, 

244 "topic": card.title, # Map title back to topic 

245 "title": card.title, 

246 "summary": card.summary, 

247 "content": card.content, 

248 "url": card.url, 

249 "source_name": card.source_name, 

250 "source_type": card.source_type, 

251 "source_id": card.source_id, 

252 "category": card.category, 

253 "tags": card.tags, 

254 "card_type": card.card_type, 

255 "published_at": card.published_at.isoformat() 

256 if card.published_at 

257 else None, 

258 "discovered_at": card.discovered_at.isoformat() 

259 if card.discovered_at 

260 else None, 

261 "created_at": card.discovered_at.isoformat() 

262 if card.discovered_at 

263 else None, # Alias for compatibility 

264 "updated_at": card.discovered_at.isoformat() 

265 if card.discovered_at 

266 else None, # Best approximation 

267 "is_read": card.is_read, 

268 "read_at": card.read_at.isoformat() if card.read_at else None, 

269 "is_saved": card.is_saved, 

270 "is_pinned": card.is_saved, # Alias for compatibility 

271 "saved_at": card.saved_at.isoformat() if card.saved_at else None, 

272 # Fields from extra_data 

273 "user_id": extra_data.get("user_id"), 

274 "parent_card_id": extra_data.get("parent_card_id"), 

275 "created_from": extra_data.get("created_from"), 

276 "is_archived": extra_data.get("is_archived", False), 

277 "metadata": extra_data.get("metadata", {}), 

278 "interaction": extra_data.get("interaction", {}), 

279 "source": { 

280 "type": card.source_type, 

281 "source_id": card.source_id, 

282 "created_from": extra_data.get("created_from", ""), 

283 "metadata": extra_data.get("metadata", {}), 

284 }, 

285 } 

286 

287 def get_by_user( 

288 self, user_id: str, limit: int = 50, offset: int = 0 

289 ) -> List[Dict[str, Any]]: 

290 """Get cards for a specific user. 

291 

292 Note: Since user_id is stored in extra_data JSON, this does a 

293 post-filter. For better performance with large datasets, 

294 consider adding a proper user_id column. 

295 """ 

296 # Get more cards than needed to account for filtering 

297 all_cards = self.list(filters=None, limit=limit * 3, offset=0) 

298 

299 # Filter by user_id from extra_data 

300 user_cards = [ 

301 card 

302 for card in all_cards 

303 if card.get("user_id") == user_id 

304 and not card.get("is_archived", False) 

305 ] 

306 

307 # Apply pagination 

308 return user_cards[offset : offset + limit] 

309 

310 def get_latest_version(self, card_id: str) -> Optional[Dict[str, Any]]: 

311 """Get the latest version of a card. 

312 

313 Note: The versioning system is not yet implemented at the database level. 

314 CardVersion is a Python dataclass for in-memory use, not a SQLAlchemy model. 

315 This method returns version info stored in extra_data if available. 

316 """ 

317 card_data = self.get(card_id) 

318 if not card_data: 

319 return None 

320 

321 # Check if version info is stored in extra_data 

322 extra_data = card_data.get("metadata", {}) 

323 if "latest_version" in extra_data: 323 ↛ 324line 323 didn't jump to line 324 because the condition on line 323 was never true

324 result: Dict[str, Any] = extra_data["latest_version"] 

325 return result 

326 

327 # Return card's current state as version 1 

328 return { 

329 "version_id": f"{card_id}_v1", 

330 "version_number": 1, 

331 "headline": card_data.get("title"), 

332 "summary": card_data.get("summary"), 

333 "card_id": card_id, 

334 } 

335 

336 def add_version(self, card_id: str, version_data: Dict[str, Any]) -> str: 

337 """Add a new version to a card. 

338 

339 Note: The versioning system stores version data in the card's extra_data 

340 field since CardVersion is not a database model. For full versioning 

341 support, a CardVersion SQLAlchemy model would need to be created. 

342 """ 

343 version_id = version_data.get("id") or self.generate_id() 

344 

345 with self.session as session: 

346 card = session.query(NewsCard).filter_by(id=card_id).first() 

347 if not card: 

348 raise ValueError(f"Card {card_id} not found") 

349 

350 # Get current version count from extra_data 

351 extra_data = card.extra_data or {} 

352 versions = extra_data.get("versions", []) 

353 version_number = len(versions) + 1 

354 

355 # Create version record 

356 version_record = { 

357 "id": version_id, 

358 "version_number": version_number, 

359 "search_query": version_data.get("search_query"), 

360 "headline": version_data.get("headline"), 

361 "summary": version_data.get("summary"), 

362 "findings": version_data.get("findings"), 

363 "sources": version_data.get("sources"), 

364 "impact_score": version_data.get("impact_score"), 

365 "topics": version_data.get("topics"), 

366 "entities": version_data.get("entities"), 

367 "created_at": datetime.now(timezone.utc).isoformat(), 

368 } 

369 

370 versions.append(version_record) 

371 extra_data["versions"] = versions 

372 extra_data["latest_version"] = version_record 

373 

374 # Update card fields with latest version info 

375 if version_data.get("headline"): 375 ↛ 377line 375 didn't jump to line 377 because the condition on line 375 was always true

376 card.title = version_data["headline"] 

377 if version_data.get("summary"): 

378 card.summary = version_data["summary"] 

379 

380 card.extra_data = extra_data 

381 session.commit() 

382 

383 logger.info(f"Added version {version_number} to card {card_id}") 

384 return version_id 

385 

386 def update_latest_info( 

387 self, card_id: str, version_data: Dict[str, Any] 

388 ) -> bool: 

389 """Update the denormalized latest version info on the card. 

390 

391 Updates the card's main fields with the latest version data. 

392 """ 

393 with self.session as session: 

394 card = session.query(NewsCard).filter_by(id=card_id).first() 

395 if not card: 

396 return False 

397 

398 # Update card's display fields 

399 if version_data.get("headline"): 399 ↛ 401line 399 didn't jump to line 401 because the condition on line 399 was always true

400 card.title = version_data["headline"] 

401 if version_data.get("summary"): 401 ↛ 405line 401 didn't jump to line 405 because the condition on line 401 was always true

402 card.summary = version_data["summary"] 

403 

404 # Store version metadata in extra_data 

405 extra_data = card.extra_data or {} 

406 extra_data["latest_version"] = { 

407 "id": version_data.get("id"), 

408 "headline": version_data.get("headline"), 

409 "summary": version_data.get("summary"), 

410 "impact_score": version_data.get("impact_score"), 

411 } 

412 card.extra_data = extra_data 

413 

414 session.commit() 

415 return True 

416 

417 def archive_card(self, card_id: str) -> bool: 

418 """Archive a card""" 

419 return self.update(card_id, {"is_archived": True}) 

420 

421 def pin_card(self, card_id: str, pinned: bool = True) -> bool: 

422 """Pin or unpin a card""" 

423 return self.update(card_id, {"is_pinned": pinned})