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
« 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"""
6from enum import Enum
7from typing import List, Dict, Any, Optional
8from datetime import datetime, timedelta, timezone
9from loguru import logger
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
19class InteractionType(Enum):
20 """Enum for card interaction types."""
22 VIEW = "view"
23 VOTE_UP = "vote_up"
24 VOTE_DOWN = "vote_down"
25 RESEARCH = "research"
26 SHARE = "share"
29class StorageManager:
30 """
31 Unified storage interface for the news system.
32 Manages all data access across modules.
33 """
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
42 # Card factory for reconstruction
43 self.card_factory = CardFactory
45 # Relevance service for business logic
46 self.relevance_service = get_relevance_service()
48 logger.info("StorageManager initialized")
50 def _get_current_session(self):
51 """Get the current user's database session.
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
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
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
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
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
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.
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
126 Returns:
127 List of cards sorted by relevance
128 """
129 try:
130 # Get user preferences
131 user_prefs = self.preferences.get(user_id)
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}
143 if card_types:
144 filters["card_type"] = card_types
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 )
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)
160 # Apply personalization
161 cards = self.relevance_service.personalize_feed(
162 cards, user_prefs, include_seen=include_seen
163 )
165 return cards[:limit]
167 except Exception:
168 logger.exception("Error getting user feed")
169 return []
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.
177 Args:
178 hours: Look back period
179 limit: Maximum cards
180 min_impact: Minimum impact score
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 )
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 )
196 except Exception:
197 logger.exception("Error getting trending news")
198 return []
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.
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
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
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
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 )
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 )
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 )
267 # Add metadata if provided
268 if metadata:
269 card.interaction[f"{interaction_type}_metadata"] = metadata
271 # Save updated card
272 return CardFactory.update_card(card)
274 except Exception:
275 logger.exception("Error recording interaction")
276 return False
278 def get_card(self, card_id: str) -> Optional[BaseCard]:
279 """
280 Get a single card by ID.
282 Args:
283 card_id: The card ID
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
294 def get_card_interactions(self, card_id: str) -> List[Dict[str, Any]]:
295 """
296 Get all interactions for a card.
298 Args:
299 card_id: The card ID
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})
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)
321 return interactions
323 except Exception:
324 logger.exception("Error getting card interactions")
325 return []
327 def update_card(self, card: BaseCard) -> bool:
328 """
329 Update a card in storage.
331 Args:
332 card: The card to update
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
343 def cleanup_old_data(self, days: int = 30) -> Dict[str, int]:
344 """
345 Clean up old data from all storages.
347 Args:
348 days: Age threshold in days
350 Returns:
351 Counts of deleted items
352 """
353 try:
354 cutoff = datetime.now(timezone.utc) - timedelta(days=days)
355 counts = {}
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
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"])
373 logger.info(f"Cleanup complete: {counts}")
374 return counts
376 except Exception:
377 logger.exception("Error during cleanup")
378 return {}