Coverage for src/local_deep_research/news/core/news_analyzer.py: 99%

165 statements  

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

1""" 

2News analyzer that produces modular output components. 

3Breaks down news analysis into separate, reusable pieces. 

4""" 

5 

6from typing import List, Dict, Any, Optional, cast 

7from datetime import datetime, timezone, UTC 

8from loguru import logger 

9 

10from .utils import generate_card_id 

11from ..utils.topic_generator import generate_topics 

12from ...config.llm_config import get_llm 

13from ...utilities.json_utils import extract_json, get_llm_response_text 

14 

15 

16class NewsAnalyzer: 

17 """ 

18 Analyzes news search results to produce modular components. 

19 

20 Instead of one big analysis, produces: 

21 - News items table 

22 - Big picture summary 

23 - Watch for (next 24-48h) 

24 - Pattern recognition 

25 - Extractable topics for subscriptions 

26 """ 

27 

28 def __init__( 

29 self, 

30 llm_client: Optional[Any] = None, 

31 settings_snapshot: Optional[Dict[str, Any]] = None, 

32 ): 

33 """ 

34 Initialize the news analyzer. 

35 

36 Args: 

37 llm_client: LLM client for analysis 

38 settings_snapshot: Optional saved settings snapshot so the 

39 LLM is constructed under the user's egress policy. The 

40 news scheduler runs in background threads without a 

41 live request context, so without an explicit snapshot 

42 the PEP guard at the top of ``get_llm`` skips and a 

43 cloud LLM can fire under require_local_endpoint. 

44 """ 

45 self._owns_llm = llm_client is None 

46 self.llm_client = llm_client or get_llm( 

47 settings_snapshot=settings_snapshot 

48 ) 

49 

50 def close(self) -> None: 

51 """Close the LLM client if this instance created it.""" 

52 from ...utilities.resource_utils import safe_close 

53 

54 if self._owns_llm: 

55 safe_close(self.llm_client, "news analyzer LLM") 

56 

57 def analyze_news( 

58 self, search_results: List[Dict[str, Any]] 

59 ) -> Dict[str, Any]: 

60 """ 

61 Analyze news search results into modular components. 

62 

63 Args: 

64 search_results: Raw search results 

65 

66 Returns: 

67 Dictionary with modular analysis components 

68 """ 

69 if not search_results: 

70 return self._empty_analysis() 

71 

72 try: 

73 # Step 1: Extract news items table 

74 logger.debug("Extracting news items") 

75 news_items = self.extract_news_items(search_results) 

76 

77 # Step 2: Generate overview components (separate LLM calls for modularity) 

78 logger.debug("Generating analysis components") 

79 components = { 

80 "items": news_items, 

81 "item_count": len(news_items), 

82 "search_result_count": len(search_results), 

83 "timestamp": datetime.now(timezone.utc).isoformat(), 

84 } 

85 

86 if news_items: 

87 # Each component is generated independently 

88 components["big_picture"] = self.generate_big_picture( 

89 news_items 

90 ) 

91 components["watch_for"] = self.generate_watch_for(news_items) 

92 components["patterns"] = self.generate_patterns(news_items) 

93 components["topics"] = self.extract_topics(news_items) 

94 components["categories"] = self._count_categories(news_items) 

95 components["impact_summary"] = self._summarize_impact( 

96 news_items 

97 ) 

98 

99 topics_list = cast(List[Any], components.get("topics", [])) 

100 logger.info( 

101 f"News analysis complete: {len(news_items)} items, {len(topics_list)} topics" 

102 ) 

103 return components 

104 

105 except Exception: 

106 logger.exception("Error analyzing news") 

107 return self._empty_analysis() 

108 

109 def extract_news_items( 

110 self, search_results: List[Dict[str, Any]], max_items: int = 10 

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

112 """ 

113 Extract structured news items from search results. 

114 

115 Args: 

116 search_results: Raw search results 

117 max_items: Maximum number of items to extract 

118 

119 Returns: 

120 List of structured news items 

121 """ 

122 if not self.llm_client: 

123 logger.warning("No LLM client available for news extraction") 

124 return [] 

125 

126 # Prepare search results for LLM 

127 snippets = self._prepare_snippets( 

128 search_results # Use all results, let LLM handle token limits 

129 ) 

130 

131 prompt = f""" 

132Extract up to {max_items} important news stories from these search results. 

133Today's date: {datetime.now(UTC).strftime("%B %d, %Y")} 

134 

135{snippets} 

136 

137For each news story, extract: 

1381. headline - 8 words max describing the story 

1392. category - A descriptive category for this news (be specific, not limited to generic categories) 

1403. summary - 3 clear sentences about what happened 

1414. impact_score - 1-10 based on significance 

1425. source_url - URL from the search results 

1436. entities - people, places, organizations mentioned 

1447. is_developing - true/false if story is still developing 

1458. time_ago - when it happened (2 hours ago, yesterday, etc) 

146 

147Return as JSON array of news items. 

148Focus on genuinely newsworthy stories. 

149""" 

150 

151 try: 

152 response = self.llm_client.invoke(prompt) 

153 content = get_llm_response_text(response) 

154 

155 # Parse JSON response 

156 news_items = extract_json(content, expected_type=list) 

157 if news_items is not None: 

158 # Validate and clean items 

159 valid_items = [] 

160 for item in news_items[:max_items]: 

161 if self._validate_news_item(item): 

162 # Generate ID 

163 item["id"] = generate_card_id() 

164 valid_items.append(item) 

165 

166 return valid_items 

167 

168 except Exception: 

169 logger.exception("Error extracting news items") 

170 

171 return [] 

172 

173 def generate_big_picture(self, news_items: List[Dict[str, Any]]) -> str: 

174 """ 

175 Generate the big picture summary of how events connect. 

176 

177 Args: 

178 news_items: Extracted news items 

179 

180 Returns: 

181 Big picture summary (3-4 sentences) 

182 """ 

183 if not self.llm_client or not news_items: 

184 return "" 

185 

186 # Prepare news summaries 

187 summaries = "\n".join( 

188 [ 

189 f"- {item['headline']}: {item.get('summary', '')[:100]}..." 

190 for item in news_items[:10] 

191 ] 

192 ) 

193 

194 prompt = f""" 

195Based on these news stories, write THE BIG PICTURE summary. 

196Connect the dots between events. What's the larger narrative? 

197Write 3-4 sentences maximum. 

198 

199News stories: 

200{summaries} 

201 

202THE BIG PICTURE:""" 

203 

204 try: 

205 response = self.llm_client.invoke(prompt) 

206 content = ( 

207 response.content 

208 if hasattr(response, "content") 

209 else str(response) 

210 ) 

211 return content.strip() 

212 except Exception: 

213 logger.exception("Error generating big picture") 

214 return "" 

215 

216 def generate_watch_for(self, news_items: List[Dict[str, Any]]) -> List[str]: 

217 """ 

218 Generate list of developments to watch for in next 24-48 hours. 

219 

220 Args: 

221 news_items: Extracted news items 

222 

223 Returns: 

224 List of bullet points 

225 """ 

226 if not self.llm_client or not news_items: 

227 return [] 

228 

229 # Focus on developing stories 

230 developing = [ 

231 item for item in news_items if item.get("is_developing", False) 

232 ] 

233 if not developing: 

234 developing = news_items[:5] 

235 

236 summaries = "\n".join( 

237 [ 

238 f"- {item['headline']}: {item.get('summary', '')[:100]}..." 

239 for item in developing 

240 ] 

241 ) 

242 

243 prompt = f""" 

244Based on these developing news stories, what should we watch for in the next 24-48 hours? 

245Write 3-5 specific, actionable items. 

246 

247Developing stories: 

248{summaries} 

249 

250WATCH FOR: 

251-""" 

252 

253 try: 

254 response = self.llm_client.invoke(prompt) 

255 content = ( 

256 response.content 

257 if hasattr(response, "content") 

258 else str(response) 

259 ) 

260 

261 # Parse bullet points 

262 lines = content.strip().split("\n") 

263 watch_items = [] 

264 for line in lines: 

265 line = line.strip() 

266 if line and line not in ["WATCH FOR:", "Watch for:"]: 

267 # Remove bullet markers 

268 line = line.lstrip("-•* ") 

269 if line: 269 ↛ 264line 269 didn't jump to line 264 because the condition on line 269 was always true

270 watch_items.append(line) 

271 

272 return watch_items[:5] 

273 

274 except Exception: 

275 logger.exception("Error generating watch items") 

276 return [] 

277 

278 def generate_patterns(self, news_items: List[Dict[str, Any]]) -> str: 

279 """ 

280 Identify emerging patterns from today's news. 

281 

282 Args: 

283 news_items: Extracted news items 

284 

285 Returns: 

286 Pattern recognition summary 

287 """ 

288 if not self.llm_client or not news_items: 

289 return "" 

290 

291 # Group by category 

292 by_category: Dict[str, List[Any]] = {} 

293 for item in news_items: 

294 cat = item.get("category", "Other") 

295 if cat not in by_category: 

296 by_category[cat] = [] 

297 by_category[cat].append(item["headline"]) 

298 

299 category_summary = "\n".join( 

300 [ 

301 f"{cat}: {len(items)} stories" 

302 for cat, items in by_category.items() 

303 ] 

304 ) 

305 

306 prompt = f""" 

307Identify emerging patterns from today's news distribution: 

308 

309{category_summary} 

310 

311Top headlines: 

312{chr(10).join([f"- {item['headline']}" for item in news_items[:10]])} 

313 

314PATTERN RECOGNITION (1-2 sentences):""" 

315 

316 try: 

317 response = self.llm_client.invoke(prompt) 

318 content = ( 

319 response.content 

320 if hasattr(response, "content") 

321 else str(response) 

322 ) 

323 return content.strip() 

324 except Exception: 

325 logger.exception("Error generating patterns") 

326 return "" 

327 

328 def extract_topics( 

329 self, news_items: List[Dict[str, Any]] 

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

331 """ 

332 Extract subscribable topics from news items. 

333 

334 Args: 

335 news_items: Extracted news items 

336 

337 Returns: 

338 List of topic dictionaries with metadata 

339 """ 

340 topics = [] 

341 

342 # Use topic generator to extract from each item 

343 for item in news_items: 

344 # Use topic generator with headline as query and summary as findings 

345 headline = item.get("headline", "") 

346 summary = item.get("summary", "") 

347 category = item.get("category", "") 

348 

349 extracted = generate_topics( 

350 query=headline, 

351 findings=summary, 

352 category=category, 

353 max_topics=3, 

354 ) 

355 

356 for topic in extracted: 

357 topics.append( 

358 { 

359 "name": topic, 

360 "source_item_id": item.get("id"), 

361 "source_headline": item.get("headline"), 

362 "category": item.get("category"), 

363 "impact_score": item.get("impact_score", 5), 

364 } 

365 ) 

366 

367 # Deduplicate and sort by frequency 

368 topic_counts = {} 

369 topic_metadata = {} 

370 

371 for topic_info in topics: 

372 name = topic_info["name"] 

373 if name not in topic_counts: 

374 topic_counts[name] = 0 

375 topic_metadata[name] = topic_info 

376 topic_counts[name] += 1 

377 

378 # Keep highest impact score 

379 if ( 

380 topic_info["impact_score"] 

381 > topic_metadata[name]["impact_score"] 

382 ): 

383 topic_metadata[name] = topic_info 

384 

385 # Create final topic list 

386 final_topics = [] 

387 for topic, count in sorted( 

388 topic_counts.items(), key=lambda x: x[1], reverse=True 

389 ): 

390 metadata = topic_metadata[topic] 

391 metadata["frequency"] = count 

392 metadata["query"] = f"{topic} latest developments news" 

393 final_topics.append(metadata) 

394 

395 return final_topics[:10] # Top 10 topics 

396 

397 def _prepare_snippets(self, search_results: List[Dict[str, Any]]) -> str: 

398 """Prepare search result snippets for LLM processing.""" 

399 snippets = [] 

400 for i, result in enumerate(search_results): 

401 snippet = f"[{i + 1}] " 

402 if result.get("title"): 

403 snippet += f"Title: {result['title']}\n" 

404 if result.get("url"): 

405 snippet += f"URL: {result['url']}\n" 

406 if result.get("snippet"): 

407 snippet += f"Snippet: {result['snippet'][:200]}...\n" 

408 elif result.get("content"): 

409 snippet += f"Content: {result['content'][:200]}...\n" 

410 

411 snippets.append(snippet) 

412 

413 return "\n".join(snippets) 

414 

415 def _validate_news_item(self, item: Dict[str, Any]) -> bool: 

416 """Validate that a news item has required fields.""" 

417 required = ["headline", "summary"] 

418 return all(field in item and item[field] for field in required) 

419 

420 def _count_categories( 

421 self, news_items: List[Dict[str, Any]] 

422 ) -> Dict[str, int]: 

423 """Count items by category.""" 

424 counts: Dict[str, int] = {} 

425 for item in news_items: 

426 cat = item.get("category", "Other") 

427 counts[cat] = counts.get(cat, 0) + 1 

428 return counts 

429 

430 def _summarize_impact( 

431 self, news_items: List[Dict[str, Any]] 

432 ) -> Dict[str, Any]: 

433 """Summarize impact scores.""" 

434 if not news_items: 

435 return {"average": 0, "high_impact_count": 0} 

436 

437 scores = [item.get("impact_score", 5) for item in news_items] 

438 return { 

439 "average": sum(scores) / len(scores), 

440 "high_impact_count": len([s for s in scores if s >= 8]), 

441 "max": max(scores), 

442 "min": min(scores), 

443 } 

444 

445 def _empty_analysis(self) -> Dict[str, Any]: 

446 """Return empty analysis structure.""" 

447 return { 

448 "items": [], 

449 "item_count": 0, 

450 "big_picture": "", 

451 "watch_for": [], 

452 "patterns": "", 

453 "topics": [], 

454 "categories": {}, 

455 "impact_summary": {"average": 0, "high_impact_count": 0}, 

456 "timestamp": datetime.now(timezone.utc).isoformat(), 

457 }