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

166 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +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_async 

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 ``get_llm`` takes its snapshot-less path, which fails 

43 closed with ``PolicyDeniedError`` for any non-local 

44 provider — the analyzer cannot be constructed at all 

45 instead of quietly reaching a cloud LLM. 

46 """ 

47 self._settings_snapshot = settings_snapshot 

48 self._owns_llm = llm_client is None 

49 self.llm_client = llm_client or get_llm( 

50 settings_snapshot=settings_snapshot 

51 ) 

52 

53 def close(self) -> None: 

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

55 from ...utilities.resource_utils import safe_close 

56 

57 if self._owns_llm: 

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

59 

60 async def analyze_news( 

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

62 ) -> Dict[str, Any]: 

63 """ 

64 Analyze news search results into modular components. 

65 

66 Args: 

67 search_results: Raw search results 

68 

69 Returns: 

70 Dictionary with modular analysis components 

71 """ 

72 if not search_results: 

73 return self._empty_analysis() 

74 

75 try: 

76 # Step 1: Extract news items table 

77 logger.debug("Extracting news items") 

78 news_items = await self.extract_news_items(search_results) 

79 

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

81 logger.debug("Generating analysis components") 

82 components = { 

83 "items": news_items, 

84 "item_count": len(news_items), 

85 "search_result_count": len(search_results), 

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

87 } 

88 

89 if news_items: 

90 # Each component is generated independently 

91 components["big_picture"] = await self.generate_big_picture( 

92 news_items 

93 ) 

94 components["watch_for"] = await self.generate_watch_for( 

95 news_items 

96 ) 

97 components["patterns"] = await self.generate_patterns( 

98 news_items 

99 ) 

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

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

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

103 news_items 

104 ) 

105 

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

107 logger.info( 

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

109 ) 

110 return components 

111 

112 except Exception: 

113 logger.exception("Error analyzing news") 

114 return self._empty_analysis() 

115 

116 async def extract_news_items( 

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

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

119 """ 

120 Extract structured news items from search results. 

121 

122 Args: 

123 search_results: Raw search results 

124 max_items: Maximum number of items to extract 

125 

126 Returns: 

127 List of structured news items 

128 """ 

129 if not self.llm_client: 

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

131 return [] 

132 

133 # Prepare search results for LLM 

134 snippets = self._prepare_snippets( 

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

136 ) 

137 

138 prompt = f""" 

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

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

141 

142{snippets} 

143 

144For each news story, extract: 

1451. headline - 8 words max describing the story 

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

1473. summary - 3 clear sentences about what happened 

1484. impact_score - 1-10 based on significance 

1495. source_url - URL from the search results 

1506. entities - people, places, organizations mentioned 

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

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

153 

154Return as JSON array of news items. 

155Focus on genuinely newsworthy stories. 

156""" 

157 

158 try: 

159 response = await self.llm_client.ainvoke(prompt) 

160 content = get_llm_response_text(response) 

161 

162 # Parse JSON response 

163 news_items = extract_json(content, expected_type=list) 

164 if news_items is not None: 

165 # Validate and clean items 

166 valid_items = [] 

167 for item in news_items[:max_items]: 

168 if self._validate_news_item(item): 

169 # Generate ID 

170 item["id"] = generate_card_id() 

171 valid_items.append(item) 

172 

173 return valid_items 

174 

175 except Exception: 

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

177 

178 return [] 

179 

180 async def generate_big_picture( 

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

182 ) -> str: 

183 """ 

184 Generate the big picture summary of how events connect. 

185 

186 Args: 

187 news_items: Extracted news items 

188 

189 Returns: 

190 Big picture summary (3-4 sentences) 

191 """ 

192 if not self.llm_client or not news_items: 

193 return "" 

194 

195 # Prepare news summaries 

196 summaries = "\n".join( 

197 [ 

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

199 for item in news_items[:10] 

200 ] 

201 ) 

202 

203 prompt = f""" 

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

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

206Write 3-4 sentences maximum. 

207 

208News stories: 

209{summaries} 

210 

211THE BIG PICTURE:""" 

212 

213 try: 

214 response = await self.llm_client.ainvoke(prompt) 

215 content = get_llm_response_text(response) 

216 return content.strip() 

217 except Exception: 

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

219 return "" 

220 

221 async def generate_watch_for( 

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

223 ) -> List[str]: 

224 """ 

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

226 

227 Args: 

228 news_items: Extracted news items 

229 

230 Returns: 

231 List of bullet points 

232 """ 

233 if not self.llm_client or not news_items: 

234 return [] 

235 

236 # Focus on developing stories 

237 developing = [ 

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

239 ] 

240 if not developing: 

241 developing = news_items[:5] 

242 

243 summaries = "\n".join( 

244 [ 

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

246 for item in developing 

247 ] 

248 ) 

249 

250 prompt = f""" 

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

252Write 3-5 specific, actionable items. 

253 

254Developing stories: 

255{summaries} 

256 

257WATCH FOR: 

258-""" 

259 

260 try: 

261 response = await self.llm_client.ainvoke(prompt) 

262 content = get_llm_response_text(response) 

263 

264 # Parse bullet points 

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

266 watch_items = [] 

267 for line in lines: 

268 line = line.strip() 

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

270 # Remove bullet markers 

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

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

273 watch_items.append(line) 

274 

275 return watch_items[:5] 

276 

277 except Exception: 

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

279 return [] 

280 

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

282 """ 

283 Identify emerging patterns from today's news. 

284 

285 Args: 

286 news_items: Extracted news items 

287 

288 Returns: 

289 Pattern recognition summary 

290 """ 

291 if not self.llm_client or not news_items: 

292 return "" 

293 

294 # Group by category 

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

296 for item in news_items: 

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

298 if cat not in by_category: 

299 by_category[cat] = [] 

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

301 

302 category_summary = "\n".join( 

303 [ 

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

305 for cat, items in by_category.items() 

306 ] 

307 ) 

308 

309 prompt = f""" 

310Identify emerging patterns from today's news distribution: 

311 

312{category_summary} 

313 

314Top headlines: 

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

316 

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

318 

319 try: 

320 response = await self.llm_client.ainvoke(prompt) 

321 content = get_llm_response_text(response) 

322 return content.strip() 

323 except Exception: 

324 logger.exception("Error generating patterns") 

325 return "" 

326 

327 async def extract_topics( 

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

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

330 """ 

331 Extract subscribable topics from news items. 

332 

333 Args: 

334 news_items: Extracted news items 

335 

336 Returns: 

337 List of topic dictionaries with metadata 

338 """ 

339 topics = [] 

340 

341 # Use topic generator to extract from each item 

342 for item in news_items: 

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

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

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

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

347 

348 extracted = await generate_topics_async( 

349 query=headline, 

350 findings=summary, 

351 category=category, 

352 max_topics=3, 

353 settings_snapshot=self._settings_snapshot, 

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 }