Coverage for src / local_deep_research / news / core / base_card.py: 99%
145 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-14 23:55 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-14 23:55 +0000
1"""
2Base card class for all news-related content.
3Following LDR's pattern from BaseSearchStrategy.
4"""
6from abc import ABC, abstractmethod
7from dataclasses import dataclass, field
8from datetime import datetime
9from typing import Dict, List, Optional, Any
11from .utils import generate_card_id, utc_now
12# Storage will be imported when needed to avoid circular import
15@dataclass
16class CardSource:
17 """Tracks the origin of each card."""
19 type: str # "news_item", "user_search", "subscription", "news_research"
20 source_id: Optional[str] = None
21 created_from: str = ""
22 metadata: Dict[str, Any] = field(default_factory=dict)
25@dataclass
26class CardVersion:
27 """Represents a version of research/content for a card."""
29 version_id: str
30 created_at: datetime
31 content: Dict[str, Any] # The actual research results
32 query_used: str
33 search_strategy: Optional[str] = None
35 def __post_init__(self):
36 if not self.version_id:
37 self.version_id = generate_card_id()
40@dataclass
41class BaseCard(ABC):
42 """
43 Abstract base class for all card types.
44 Following LDR's pattern of base classes with common functionality.
45 """
47 # Required fields
48 topic: str
49 source: CardSource
50 user_id: str
52 # Optional fields with defaults
53 card_id: Optional[str] = None
54 parent_card_id: Optional[str] = None
55 metadata: Dict[str, Any] = field(default_factory=dict)
57 # Automatically generated fields
58 id: str = field(init=False)
59 created_at: datetime = field(init=False)
60 updated_at: datetime = field(init=False)
61 versions: List[CardVersion] = field(default_factory=list, init=False)
62 interaction: Dict[str, Any] = field(init=False)
64 # Storage and callback fields
65 storage: Optional[Any] = field(default=None, init=False)
66 progress_callback: Optional[Any] = field(default=None, init=False)
68 def __post_init__(self):
69 """Initialize generated fields after dataclass initialization."""
70 self.id = self.card_id or generate_card_id()
71 self.created_at = utc_now()
72 self.updated_at = utc_now()
73 self.interaction = {
74 "votes_up": 0,
75 "votes_down": 0,
76 "views": 0,
77 "shares": 0,
78 }
80 def set_progress_callback(self, callback) -> None:
81 """Set a callback function to receive progress updates."""
82 self.progress_callback = callback
84 def _update_progress(
85 self,
86 message: str,
87 progress_percent: Optional[int] = None,
88 metadata: Optional[dict] = None,
89 ) -> None:
90 """Send a progress update via the callback if available."""
91 if self.progress_callback:
92 self.progress_callback(message, progress_percent, metadata or {})
94 def save(self) -> str:
95 """Save card to database"""
96 if self.storage is None:
97 raise RuntimeError("Storage must be set before calling save()")
98 card_data = {
99 "id": self.id,
100 "user_id": self.user_id,
101 "topic": self.topic,
102 "card_type": self.get_card_type(),
103 "source_type": self.source.type,
104 "source_id": self.source.source_id,
105 "created_from": self.source.created_from,
106 "parent_card_id": self.parent_card_id,
107 "metadata": self.metadata,
108 }
109 result: str = self.storage.create(card_data)
110 return result
112 def add_version(
113 self, research_results: Dict[str, Any], query: str, strategy: str
114 ) -> str:
115 """Add a new version with research results"""
116 version_data = {
117 "search_query": query,
118 "research_result": research_results,
119 "headline": self._extract_headline(research_results),
120 "summary": self._extract_summary(research_results),
121 "findings": research_results.get("findings", []),
122 "sources": research_results.get("sources", []),
123 "impact_score": self._calculate_impact(research_results),
124 "topics": self._extract_topics(research_results),
125 "entities": self._extract_entities(research_results),
126 "strategy": strategy,
127 }
129 if self.storage is None:
130 raise RuntimeError(
131 "Storage must be set before calling add_version()"
132 )
133 version_id: str = self.storage.add_version(self.id, version_data)
135 # Add to local versions list
136 version = CardVersion(
137 version_id=version_id,
138 created_at=utc_now(),
139 content=research_results,
140 query_used=query,
141 search_strategy=strategy,
142 )
143 self.versions.append(version)
144 self.updated_at = utc_now()
146 return version_id
148 def get_latest_version(self) -> Optional[CardVersion]:
149 """Get the most recent version of this card."""
150 if not self.versions:
151 return None
152 return max(self.versions, key=lambda v: v.created_at)
154 def to_base_dict(self) -> Dict[str, Any]:
155 """Convert base card attributes to dictionary."""
156 return {
157 "id": self.id,
158 "topic": self.topic,
159 "user_id": self.user_id,
160 "created_at": self.created_at.isoformat()
161 if self.created_at
162 else None,
163 "updated_at": self.updated_at.isoformat()
164 if self.updated_at
165 else None,
166 "source": {
167 "type": self.source.type,
168 "source_id": self.source.source_id,
169 "created_from": self.source.created_from,
170 "metadata": self.source.metadata,
171 },
172 "versions_count": len(self.versions),
173 "parent_card_id": self.parent_card_id,
174 "metadata": self.metadata,
175 "interaction": self.interaction,
176 "card_type": self.get_card_type(),
177 }
179 @abstractmethod
180 def get_card_type(self) -> str:
181 """Return the card type (news, research, update, overview)"""
182 pass
184 @abstractmethod
185 def to_dict(self) -> Dict[str, Any]:
186 """
187 Convert card to dictionary representation.
188 Must be implemented by subclasses.
189 """
190 pass
192 # Helper methods for extracting data from research results
193 def _extract_headline(self, result: Dict[str, Any]) -> str:
194 """Extract headline from research result"""
195 # Try different possible fields
196 return str(
197 result.get("headline")
198 or result.get("title")
199 or result.get("query", "")[:100]
200 )
202 def _extract_summary(self, result: Dict[str, Any]) -> str:
203 """Extract summary from research result"""
204 return str(
205 result.get("summary")
206 or result.get("current_knowledge")
207 or result.get("formatted_findings", "")[:500]
208 )
210 def _calculate_impact(self, result: Dict[str, Any]) -> int:
211 """Calculate impact score (1-10)"""
212 # Simple heuristic based on findings count and sources
213 findings_count = len(result.get("findings", []))
214 sources_count = len(result.get("sources", []))
216 score = min(10, 5 + (findings_count // 5) + (sources_count // 3))
217 return max(1, score)
219 def _extract_topics(self, result: Dict[str, Any]) -> List[str]:
220 """Extract topics from research result"""
221 # Could be enhanced with NLP
222 topics: List[str] = result.get("topics", [])
223 if not topics and "query" in result:
224 # Simple keyword extraction from query
225 words = result["query"].lower().split()
226 topics = [w for w in words if len(w) > 4][:5]
227 return topics
229 def _extract_entities(self, result: Dict[str, Any]) -> Dict[str, List[str]]:
230 """Extract entities from research result"""
231 # Placeholder - would use NER in production
232 entities: Dict[str, List[str]] = result.get(
233 "entities", {"people": [], "places": [], "organizations": []}
234 )
235 return entities
238@dataclass
239class NewsCard(BaseCard):
240 """Card representing a news item with potential for research."""
242 # News-specific fields with defaults
243 headline: str = ""
244 summary: str = ""
245 category: str = "General"
246 impact_score: int = 5
247 entities: Dict[str, List[str]] = field(
248 default_factory=lambda: {
249 "people": [],
250 "places": [],
251 "organizations": [],
252 }
253 )
254 topics_extracted: List[str] = field(default_factory=list)
255 is_developing: bool = False
256 time_ago: str = "recent"
257 source_url: str = ""
258 analysis: str = ""
259 surprising_element: Optional[str] = None
261 def __post_init__(self):
262 """Initialize parent and set headline default."""
263 super().__post_init__()
264 if not self.headline:
265 self.headline = self.topic
267 def get_card_type(self) -> str:
268 """Return the card type"""
269 return "news"
271 def to_dict(self) -> Dict[str, Any]:
272 data = self.to_base_dict()
273 data.update(
274 {
275 "headline": self.headline,
276 "summary": self.summary,
277 "category": self.category,
278 "impact_score": self.impact_score,
279 "entities": self.entities,
280 "topics_extracted": self.topics_extracted,
281 "is_developing": self.is_developing,
282 "time_ago": self.time_ago,
283 }
284 )
285 return data
288@dataclass
289class ResearchCard(BaseCard):
290 """Card representing deeper research on a topic."""
292 # Research-specific fields
293 research_depth: str = "quick" # "quick", "detailed", "report"
294 key_findings: List[str] = field(default_factory=list)
295 sources_count: int = 0
297 def get_card_type(self) -> str:
298 """Return the card type"""
299 return "research"
301 def to_dict(self) -> Dict[str, Any]:
302 data = self.to_base_dict()
303 data.update(
304 {
305 "research_depth": self.research_depth,
306 "key_findings": self.key_findings,
307 "sources_count": self.sources_count,
308 }
309 )
310 return data
313@dataclass
314class UpdateCard(BaseCard):
315 """Card representing updates or notifications."""
317 # Update-specific fields
318 update_type: str = "new_stories" # "new_stories", "breaking", "follow_up"
319 count: int = 0
320 preview_items: List[Any] = field(default_factory=list)
321 since: datetime = field(init=False)
323 def __post_init__(self):
324 """Initialize parent and set since timestamp."""
325 super().__post_init__()
326 self.since = utc_now()
328 def get_card_type(self) -> str:
329 """Return the card type"""
330 return "update"
332 def to_dict(self) -> Dict[str, Any]:
333 data = self.to_base_dict()
334 data.update(
335 {
336 "update_type": self.update_type,
337 "count": self.count,
338 "preview_items": self.preview_items,
339 "since": self.since.isoformat(),
340 }
341 )
342 return data
345@dataclass
346class OverviewCard(BaseCard):
347 """Special card type for dashboard/overview display."""
349 # Override topic default for overview cards
350 topic: str = field(default="News Overview", init=False)
352 # Overview-specific fields
353 stats: Dict[str, Any] = field(
354 default_factory=lambda: {
355 "total_new": 0,
356 "breaking": 0,
357 "relevant": 0,
358 "categories": {},
359 }
360 )
361 summary: str = ""
362 top_stories: List[Any] = field(default_factory=list)
363 trend_analysis: str = ""
365 def get_card_type(self) -> str:
366 """Return the card type"""
367 return "overview"
369 def to_dict(self) -> Dict[str, Any]:
370 data = self.to_base_dict()
371 data.update(
372 {
373 "stats": self.stats,
374 "summary": self.summary,
375 "top_stories": self.top_stories,
376 "trend_analysis": self.trend_analysis,
377 }
378 )
379 return data