Coverage for src/local_deep_research/news/core/storage_manager.py: 94%

154 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +0000

1""" 

2Centralized storage manager for all news system data. 

3Provides unified access to cards, subscriptions, ratings, and preferences. 

4""" 

5 

6from enum import Enum 

7from typing import List, Dict, Any, Optional 

8from datetime import datetime, timedelta, timezone 

9from loguru import logger 

10 

11from .card_factory import CardFactory 

12from .base_card import BaseCard 

13from .card_storage import SQLCardStorage 

14from .relevance_service import get_relevance_service 

15from ..rating_system.storage import SQLRatingStorage 

16from ..preference_manager.storage import SQLPreferenceStorage 

17 

18 

19class InteractionType(Enum): 

20 """Enum for card interaction types.""" 

21 

22 VIEW = "view" 

23 VOTE_UP = "vote_up" 

24 VOTE_DOWN = "vote_down" 

25 RESEARCH = "research" 

26 SHARE = "share" 

27 

28 

29class StorageManager: 

30 """ 

31 Unified storage interface for the news system. 

32 Manages all data access across modules. 

33 """ 

34 

35 def __init__(self): 

36 """Initialize storage interfaces.""" 

37 # Storage interfaces will be created on demand 

38 self._cards = None 

39 self._ratings = None 

40 self._preferences = None 

41 

42 # Card factory for reconstruction 

43 self.card_factory = CardFactory 

44 

45 # Relevance service for business logic 

46 self.relevance_service = get_relevance_service() 

47 

48 logger.info("StorageManager initialized") 

49 

50 def _get_current_session(self): 

51 """Get the current user's database session. 

52 

53 Resolves the authenticated username from the FastAPI request 

54 contextvar (set by DatabaseMiddleware) and returns the thread- 

55 local SQLCipher session for that user. Returns None when there 

56 is no authenticated user in the current context — callers must 

57 handle that (typically by raising RuntimeError). 

58 """ 

59 from ...utilities.request_context import get_current_username 

60 from ...database.session_context import get_user_db_session 

61 

62 username = get_current_username() 

63 if not username: 

64 return None 

65 try: 

66 with get_user_db_session(username) as db_session: 

67 # get_user_db_session yields a thread-local session that 

68 # outlives the `with` block, so returning it is safe. 

69 return db_session 

70 except Exception: 

71 logger.exception( 

72 f"Failed to resolve news DB session for user {username}" 

73 ) 

74 return None 

75 

76 @property 

77 def cards(self): 

78 """Get cards storage interface.""" 

79 session = self._get_current_session() 

80 if session: 

81 return SQLCardStorage(session) 

82 # For now, create storage without session 

83 # This will fail when trying to use it 

84 if self._cards is None: 84 ↛ 86line 84 didn't jump to line 86 because the condition on line 84 was always true

85 raise RuntimeError("No database session available for news storage") 

86 return self._cards 

87 

88 @property 

89 def ratings(self): 

90 """Get ratings storage interface.""" 

91 session = self._get_current_session() 

92 if session: 

93 return SQLRatingStorage(session) 

94 if self._ratings is None: 94 ↛ 96line 94 didn't jump to line 96 because the condition on line 94 was always true

95 raise RuntimeError("No database session available for news storage") 

96 return self._ratings 

97 

98 @property 

99 def preferences(self): 

100 """Get preferences storage interface.""" 

101 session = self._get_current_session() 

102 if session: 

103 return SQLPreferenceStorage(session) 

104 if self._preferences is None: 104 ↛ 106line 104 didn't jump to line 106 because the condition on line 104 was always true

105 raise RuntimeError("No database session available for news storage") 

106 return self._preferences 

107 

108 def get_user_feed( 

109 self, 

110 user_id: str, 

111 limit: int = 20, 

112 offset: int = 0, 

113 include_seen: bool = True, 

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

115 ) -> List[BaseCard]: 

116 """ 

117 Get personalized news feed for a user. 

118 

119 Args: 

120 user_id: The user ID 

121 limit: Maximum cards to return 

122 offset: Pagination offset 

123 include_seen: Whether to include already viewed cards 

124 card_types: Filter by card types 

125 

126 Returns: 

127 List of cards sorted by relevance 

128 """ 

129 try: 

130 # Get user preferences 

131 user_prefs = self.preferences.get(user_id) 

132 

133 # Get recent cards 

134 if user_prefs and user_prefs.get("liked_categories"): 

135 # Filter by user's preferred categories 

136 filters = { 

137 "user_id": user_id, 

138 "categories": user_prefs["liked_categories"], 

139 } 

140 else: 

141 filters = {"user_id": user_id} 

142 

143 if card_types: 

144 filters["card_type"] = card_types 

145 

146 # Get cards from storage 

147 cards_data = self.cards.list( 

148 filters=filters, 

149 limit=limit * 2, # Get extra to filter 

150 offset=offset, 

151 ) 

152 

153 # Reconstruct card objects 

154 cards = [] 

155 for data in cards_data: 

156 card = CardFactory.load_card(data["id"]) 

157 if card: 157 ↛ 155line 157 didn't jump to line 155 because the condition on line 157 was always true

158 cards.append(card) 

159 

160 # Apply personalization 

161 cards = self.relevance_service.personalize_feed( 

162 cards, user_prefs, include_seen=include_seen 

163 ) 

164 

165 return cards[:limit] 

166 

167 except Exception: 

168 logger.exception("Error getting user feed") 

169 return [] 

170 

171 def get_trending_news( 

172 self, hours: int = 24, limit: int = 10, min_impact: int = 7 

173 ) -> List[BaseCard]: 

174 """ 

175 Get trending news across all users. 

176 

177 Args: 

178 hours: Look back period 

179 limit: Maximum cards 

180 min_impact: Minimum impact score 

181 

182 Returns: 

183 List of trending news cards 

184 """ 

185 try: 

186 # Get recent high-impact cards 

187 cards = CardFactory.get_recent_cards( 

188 hours=hours, card_types=["news"], limit=limit * 2 

189 ) 

190 

191 # Use relevance service to filter and sort trending 

192 return self.relevance_service.filter_trending( 

193 cards, min_impact=min_impact, limit=limit 

194 ) 

195 

196 except Exception: 

197 logger.exception("Error getting trending news") 

198 return [] 

199 

200 def record_interaction( 

201 self, 

202 user_id: str, 

203 card_id: str, 

204 interaction_type: InteractionType, 

205 metadata: Optional[Dict] = None, 

206 ) -> bool: 

207 """ 

208 Record user interaction with a card. 

209 

210 Args: 

211 user_id: The user 

212 card_id: The card 

213 interaction_type: Type of interaction (view, vote, share, etc.) 

214 metadata: Additional data 

215 

216 Returns: 

217 Success status 

218 """ 

219 try: 

220 # Load the card 

221 card = CardFactory.load_card(card_id) 

222 if not card: 

223 return False 

224 

225 # Update interaction data 

226 if interaction_type == InteractionType.VIEW: 

227 card.interaction["viewed"] = True 

228 card.interaction["last_viewed"] = datetime.now(timezone.utc) 

229 card.interaction["views"] = card.interaction.get("views", 0) + 1 

230 

231 elif interaction_type == InteractionType.VOTE_UP: 

232 card.interaction["voted"] = "up" 

233 card.interaction["votes_up"] = ( 

234 card.interaction.get("votes_up", 0) + 1 

235 ) 

236 # Record in ratings 

237 self.ratings.save( 

238 { 

239 "user_id": user_id, 

240 "card_id": card_id, 

241 "rating_type": "relevance", 

242 "value": 1, 

243 } 

244 ) 

245 

246 elif interaction_type == InteractionType.VOTE_DOWN: 

247 card.interaction["voted"] = "down" 

248 card.interaction["votes_down"] = ( 

249 card.interaction.get("votes_down", 0) + 1 

250 ) 

251 # Record in ratings 

252 self.ratings.save( 

253 { 

254 "user_id": user_id, 

255 "card_id": card_id, 

256 "rating_type": "relevance", 

257 "value": -1, 

258 } 

259 ) 

260 

261 elif interaction_type == InteractionType.RESEARCH: 261 ↛ 268line 261 didn't jump to line 268 because the condition on line 261 was always true

262 card.interaction["researched"] = True 

263 card.interaction["research_count"] = ( 

264 card.interaction.get("research_count", 0) + 1 

265 ) 

266 

267 # Add metadata if provided 

268 if metadata: 

269 card.interaction[f"{interaction_type}_metadata"] = metadata 

270 

271 # Save updated card 

272 return CardFactory.update_card(card) 

273 

274 except Exception: 

275 logger.exception("Error recording interaction") 

276 return False 

277 

278 def get_card(self, card_id: str) -> Optional[BaseCard]: 

279 """ 

280 Get a single card by ID. 

281 

282 Args: 

283 card_id: The card ID 

284 

285 Returns: 

286 Card object or None 

287 """ 

288 try: 

289 return CardFactory.load_card(card_id) 

290 except Exception: 

291 logger.exception("Error getting card") 

292 return None 

293 

294 def get_card_interactions(self, card_id: str) -> List[Dict[str, Any]]: 

295 """ 

296 Get all interactions for a card. 

297 

298 Args: 

299 card_id: The card ID 

300 

301 Returns: 

302 List of interaction records 

303 """ 

304 try: 

305 # Get ratings which represent votes 

306 ratings = self.ratings.list({"card_id": card_id}) 

307 

308 # Convert to interaction format 

309 interactions = [] 

310 for rating in ratings: 

311 interaction = { 

312 "user_id": rating.get("user_id"), 

313 "interaction_type": "vote", 

314 "interaction_data": { 

315 "vote": "up" if rating.get("value", 0) > 0 else "down" 

316 }, 

317 "timestamp": rating.get("created_at"), 

318 } 

319 interactions.append(interaction) 

320 

321 return interactions 

322 

323 except Exception: 

324 logger.exception("Error getting card interactions") 

325 return [] 

326 

327 def update_card(self, card: BaseCard) -> bool: 

328 """ 

329 Update a card in storage. 

330 

331 Args: 

332 card: The card to update 

333 

334 Returns: 

335 Success status 

336 """ 

337 try: 

338 return CardFactory.update_card(card) 

339 except Exception: 

340 logger.exception("Error updating card") 

341 return False 

342 

343 def cleanup_old_data(self, days: int = 30) -> Dict[str, int]: 

344 """ 

345 Clean up old data from all storages. 

346 

347 Args: 

348 days: Age threshold in days 

349 

350 Returns: 

351 Counts of deleted items 

352 """ 

353 try: 

354 cutoff = datetime.now(timezone.utc) - timedelta(days=days) 

355 counts = {} 

356 

357 # Clean old cards (except subscribed ones) 

358 old_cards = self.cards.list( 

359 {"created_before": cutoff, "not_subscribed": True} 

360 ) 

361 deleted_cards = 0 

362 for card_data in old_cards: 

363 if self.cards.delete(card_data["id"]): 363 ↛ 362line 363 didn't jump to line 362 because the condition on line 363 was always true

364 deleted_cards += 1 

365 counts["cards"] = deleted_cards 

366 

367 # Clean old ratings 

368 old_ratings = self.ratings.list({"created_before": cutoff}) 

369 counts["ratings"] = len(old_ratings) 

370 for rating in old_ratings: 

371 self.ratings.delete(rating["id"]) 

372 

373 logger.info(f"Cleanup complete: {counts}") 

374 return counts 

375 

376 except Exception: 

377 logger.exception("Error during cleanup") 

378 return {}