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

127 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-14 23:55 +0000

1""" 

2Factory for creating and managing different types of cards. 

3Handles card creation, loading, and type registration. 

4""" 

5 

6import uuid 

7from typing import Dict, Type, Optional, Any, List, cast 

8from loguru import logger 

9 

10from .base_card import ( 

11 BaseCard, 

12 CardSource, 

13 NewsCard, 

14 ResearchCard, 

15 UpdateCard, 

16 OverviewCard, 

17) 

18from .card_storage import SQLCardStorage 

19# from ..database import init_news_database # Not needed - tables created on demand 

20 

21 

22class CardFactory: 

23 """ 

24 Factory for creating and managing cards. 

25 Provides a unified interface for card operations. 

26 """ 

27 

28 # Registry of card types 

29 _card_types: Dict[str, Type[BaseCard]] = { 

30 "news": NewsCard, 

31 "research": ResearchCard, 

32 "update": UpdateCard, 

33 "overview": OverviewCard, 

34 } 

35 

36 # Storage instance (singleton) 

37 _storage: Optional[SQLCardStorage] = None 

38 

39 @classmethod 

40 def register_card_type( 

41 cls, type_name: str, card_class: Type[BaseCard] 

42 ) -> None: 

43 """ 

44 Register a new card type. 

45 

46 Args: 

47 type_name: The name identifier for this card type 

48 card_class: The class to use for this type 

49 """ 

50 if not issubclass(card_class, BaseCard): 

51 raise ValueError(f"{card_class} must be a subclass of BaseCard") 

52 

53 cls._card_types[type_name] = card_class 

54 logger.info( 

55 f"Registered card type: {type_name} -> {card_class.__name__}" 

56 ) 

57 

58 @classmethod 

59 def get_storage(cls, session=None) -> SQLCardStorage: 

60 """Get or create the storage instance. 

61 

62 Args: 

63 session: SQLAlchemy session. If not provided, attempts to get 

64 session from Flask context (g.db_session). 

65 

66 Returns: 

67 SQLCardStorage instance 

68 

69 Raises: 

70 RuntimeError: If no session is available 

71 """ 

72 if session is not None: 

73 # Return a fresh storage with the provided session 

74 return SQLCardStorage(session) 

75 

76 # Try to get session from Flask context (lazy creation) 

77 try: 

78 from flask import has_app_context 

79 

80 if has_app_context(): 80 ↛ 81line 80 didn't jump to line 81 because the condition on line 80 was never true

81 from ...database.session_context import get_g_db_session 

82 

83 db_session = get_g_db_session() 

84 if db_session: 

85 return SQLCardStorage(db_session) 

86 except ImportError: 

87 pass 

88 

89 raise RuntimeError( 

90 "No database session available. Pass a session to get_storage() " 

91 "or ensure this is called within a Flask request context with " 

92 "g.db_session set." 

93 ) 

94 

95 @classmethod 

96 def create_card( 

97 cls, 

98 card_type: str, 

99 topic: str, 

100 source: CardSource, 

101 user_id: str, 

102 **kwargs, 

103 ) -> BaseCard: 

104 """ 

105 Create a new card of the specified type. 

106 

107 Args: 

108 card_type: Type of card to create ('news', 'research', etc.) 

109 topic: The card topic 

110 source: Source information for the card 

111 user_id: ID of the user creating the card 

112 **kwargs: Additional arguments for the specific card type 

113 

114 Returns: 

115 The created card instance 

116 

117 Raises: 

118 ValueError: If card_type is not registered 

119 """ 

120 if card_type not in cls._card_types: 

121 raise ValueError( 

122 f"Unknown card type: {card_type}. " 

123 f"Available types: {list(cls._card_types.keys())}" 

124 ) 

125 

126 # Generate unique ID 

127 card_id = str(uuid.uuid4()) 

128 

129 # Create the card instance 

130 card_class = cls._card_types[card_type] 

131 card = card_class( 

132 card_id=card_id, 

133 topic=topic, 

134 source=source, 

135 user_id=user_id, 

136 **kwargs, 

137 ) 

138 

139 # Save to storage 

140 storage = cls.get_storage() 

141 card_data = card.to_dict() 

142 storage.create(card_data) 

143 

144 logger.info(f"Created {card_type} card: {card_id} - {topic}") 

145 return card 

146 

147 @classmethod 

148 def load_card(cls, card_id: str) -> Optional[BaseCard]: 

149 """ 

150 Load a card from storage. 

151 

152 Args: 

153 card_id: The ID of the card to load 

154 

155 Returns: 

156 The card instance or None if not found 

157 """ 

158 storage = cls.get_storage() 

159 card_data = storage.get(card_id) 

160 

161 if not card_data: 

162 logger.warning(f"Card not found: {card_id}") 

163 return None 

164 

165 # Use the helper method to reconstruct the card 

166 return cls._reconstruct_card(card_data) 

167 

168 @classmethod 

169 def get_user_cards( 

170 cls, 

171 user_id: str, 

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

173 limit: int = 20, 

174 offset: int = 0, 

175 ) -> List[BaseCard]: 

176 """ 

177 Get cards for a specific user. 

178 

179 Args: 

180 user_id: The user ID 

181 card_types: Optional list of card types to filter 

182 limit: Maximum number of cards to return 

183 offset: Offset for pagination 

184 

185 Returns: 

186 List of card instances 

187 """ 

188 storage = cls.get_storage() 

189 

190 # Build filter 

191 filters: Dict[str, Any] = {"user_id": user_id} 

192 if card_types: 

193 filters["card_type"] = card_types 

194 

195 # Get card data 

196 cards_data = storage.list(filters=filters, limit=limit, offset=offset) 

197 

198 # Reconstruct cards 

199 cards = [] 

200 for data in cards_data: 

201 card = cls._reconstruct_card(data) 

202 if card: 

203 cards.append(card) 

204 

205 return cards 

206 

207 @classmethod 

208 def get_recent_cards( 

209 cls, 

210 hours: int = 24, 

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

212 limit: int = 50, 

213 ) -> List[BaseCard]: 

214 """ 

215 Get recent cards across all users. 

216 

217 Args: 

218 hours: How many hours back to look 

219 card_types: Optional list of types to filter 

220 limit: Maximum number of cards 

221 

222 Returns: 

223 List of recent cards 

224 """ 

225 storage = cls.get_storage() 

226 

227 # Get recent card data 

228 cards_data = storage.get_recent( 

229 hours=hours, card_types=card_types, limit=limit 

230 ) 

231 

232 # Reconstruct cards 

233 cards = [] 

234 for data in cards_data: 

235 card = cls._reconstruct_card(data) 

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

237 cards.append(card) 

238 

239 return cards 

240 

241 @classmethod 

242 def update_card(cls, card: BaseCard, session=None) -> bool: 

243 """ 

244 Update a card in storage. 

245 

246 Args: 

247 card: The card to update 

248 session: Optional SQLAlchemy session 

249 

250 Returns: 

251 True if successful 

252 """ 

253 storage = cls.get_storage(session) 

254 # Convert card to dict for storage.update() which expects (id, data) 

255 card_data = card.to_dict() 

256 # Include interaction data for updates 

257 card_data["interaction"] = card.interaction 

258 return storage.update(card.id, card_data) 

259 

260 @classmethod 

261 def delete_card(cls, card_id: str) -> bool: 

262 """ 

263 Delete a card from storage. 

264 

265 Args: 

266 card_id: ID of the card to delete 

267 

268 Returns: 

269 True if successful 

270 """ 

271 storage = cls.get_storage() 

272 return storage.delete(card_id) 

273 

274 @classmethod 

275 def _reconstruct_card(cls, card_data: Dict[str, Any]) -> Optional[BaseCard]: 

276 """ 

277 Helper to reconstruct a card from storage data. 

278 

279 Args: 

280 card_data: Dictionary of card data 

281 

282 Returns: 

283 Reconstructed card or None 

284 """ 

285 from datetime import datetime 

286 

287 try: 

288 # Handle enum card_type - could be string or enum 

289 card_type = card_data.get("card_type", "news") 

290 if hasattr(card_type, "value"): 

291 card_type = card_type.value 

292 

293 if card_type not in cls._card_types: 

294 logger.error(f"Unknown card type: {card_type}") 

295 return None 

296 

297 # Extract source - may be dict or may need construction 

298 source_data = card_data.get("source", {}) 

299 if not source_data: 

300 # Construct from flat fields 

301 source_data = { 

302 "type": card_data.get("source_type", "unknown"), 

303 "source_id": card_data.get("source_id"), 

304 "created_from": card_data.get("created_from", ""), 

305 "metadata": {}, 

306 } 

307 source = CardSource( 

308 type=source_data.get("type", "unknown"), 

309 source_id=source_data.get("source_id"), 

310 created_from=source_data.get("created_from", ""), 

311 metadata=source_data.get("metadata", {}), 

312 ) 

313 

314 # Get user_id, handling None case 

315 user_id = card_data.get("user_id") or "unknown" 

316 

317 # Create card 

318 card_class = cls._card_types[card_type] 

319 card = card_class( 

320 card_id=card_data["id"], 

321 topic=card_data.get("topic") or card_data.get("title", ""), 

322 source=source, 

323 user_id=user_id, 

324 ) 

325 

326 # Restore attributes - handle ISO string dates 

327 created_at = card_data.get("created_at") 

328 if created_at: 

329 if isinstance(created_at, str): 

330 card.created_at = datetime.fromisoformat( 

331 created_at.replace("Z", "+00:00") 

332 ) 

333 else: 

334 card.created_at = created_at 

335 

336 updated_at = card_data.get("updated_at") 

337 if updated_at: 

338 if isinstance(updated_at, str): 

339 card.updated_at = datetime.fromisoformat( 

340 updated_at.replace("Z", "+00:00") 

341 ) 

342 else: 

343 card.updated_at = updated_at 

344 

345 card.versions = card_data.get("versions", []) 

346 card.metadata = card_data.get("metadata", {}) 

347 card.interaction = card_data.get("interaction", {}) 

348 

349 return card 

350 

351 except Exception: 

352 logger.exception("Error reconstructing card") 

353 return None 

354 

355 @classmethod 

356 def create_news_card_from_analysis( 

357 cls, 

358 news_item: Dict[str, Any], 

359 source_search_id: str, 

360 user_id: str, 

361 additional_metadata: Optional[Dict[str, Any]] = None, 

362 ) -> NewsCard: 

363 """ 

364 Create a news card from analyzed news data. 

365 

366 Args: 

367 news_item: Dictionary with news data from analyzer 

368 source_search_id: ID of the search that found this 

369 user_id: User who initiated the search 

370 

371 Returns: 

372 Created NewsCard 

373 """ 

374 source = CardSource( 

375 type="news_search", 

376 source_id=source_search_id, 

377 created_from="News analysis", 

378 metadata={"analyzer_version": "1.0", "extraction_method": "llm"}, 

379 ) 

380 

381 # Merge additional metadata if provided 

382 metadata = news_item.get("metadata", {}) 

383 if additional_metadata: 

384 metadata.update(additional_metadata) 

385 

386 # Create the news card with all the analyzed data 

387 card = cls.create_card( 

388 card_type="news", 

389 topic=news_item.get("headline", "Untitled"), 

390 source=source, 

391 user_id=user_id, 

392 category=news_item.get("category", "Other"), 

393 summary=news_item.get("summary", ""), 

394 analysis=news_item.get("analysis", ""), 

395 impact_score=news_item.get("impact_score", 5), 

396 entities=news_item.get("entities", {}), 

397 topics=news_item.get("topics", []), 

398 source_url=news_item.get("source_url", ""), 

399 is_developing=news_item.get("is_developing", False), 

400 surprising_element=news_item.get("surprising_element"), 

401 metadata=metadata, 

402 ) 

403 

404 return cast(NewsCard, card) 

405 

406 

407# Create convenience functions at module level 

408def create_card(card_type: str, **kwargs) -> BaseCard: 

409 """Convenience function to create a card.""" 

410 return CardFactory.create_card(card_type, **kwargs) 

411 

412 

413def load_card(card_id: str) -> Optional[BaseCard]: 

414 """Convenience function to load a card.""" 

415 return CardFactory.load_card(card_id)