Coverage for src/local_deep_research/news/recommender/topic_based.py: 97%

115 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-19 23:35 +0000

1""" 

2Topic-based recommender that generates recommendations from news topics. 

3This is the primary recommender for v1. 

4""" 

5 

6from typing import List, Dict, Any, Optional 

7from loguru import logger 

8 

9from .base_recommender import BaseRecommender 

10from ..core.base_card import NewsCard 

11from ..core.card_factory import CardFactory 

12from ...search_system import AdvancedSearchSystem 

13from ...security import sanitize_for_log 

14from ...security.egress.policy import PolicyDeniedError 

15 

16 

17class TopicBasedRecommender(BaseRecommender): 

18 """ 

19 Recommends news based on topics extracted from recent news analysis. 

20 

21 This recommender: 

22 1. Gets recent news topics from the topic registry 

23 2. Filters based on user preferences 

24 3. Generates search queries for interesting topics 

25 4. Creates NewsCards from the results 

26 """ 

27 

28 def __init__(self, **kwargs): 

29 """Initialize the topic-based recommender.""" 

30 super().__init__(**kwargs) 

31 self.max_recommendations = 5 # Default limit 

32 

33 def generate_recommendations( 

34 self, user_id: str, context: Optional[Dict[str, Any]] = None 

35 ) -> List[NewsCard]: 

36 """ 

37 Generate recommendations based on trending topics. 

38 

39 Args: 

40 user_id: User to generate recommendations for 

41 context: Optional context like current news being viewed 

42 

43 Returns: 

44 List of NewsCard recommendations 

45 """ 

46 logger.info( 

47 f"Generating topic-based recommendations for user {user_id}" 

48 ) 

49 

50 recommendations = [] 

51 

52 try: 

53 # Update progress 

54 self._update_progress("Getting trending topics", 10) 

55 

56 # Get trending topics 

57 trending_topics = self._get_trending_topics(context) 

58 

59 # Filter by user preferences 

60 self._update_progress("Applying user preferences", 30) 

61 preferences = self._get_user_preferences(user_id) 

62 filtered_topics = self._filter_topics_by_preferences( 

63 trending_topics, preferences 

64 ) 

65 

66 # Generate recommendations for top topics 

67 self._update_progress("Generating news searches", 50) 

68 

69 for i, topic in enumerate( 

70 filtered_topics[: self.max_recommendations] 

71 ): 

72 progress = 50 + ( 

73 40 * i / len(filtered_topics[: self.max_recommendations]) 

74 ) 

75 self._update_progress(f"Searching for: {topic}", int(progress)) 

76 

77 # Create search query 

78 query = self._generate_topic_query(topic) 

79 

80 # Register with priority manager 

81 try: 

82 # Execute search 

83 card = self._create_recommendation_card( 

84 topic, query, user_id 

85 ) 

86 if card: 

87 recommendations.append(card) 

88 

89 except Exception: 

90 logger.exception( 

91 f"Error creating recommendation for topic '{sanitize_for_log(topic)}'" 

92 ) 

93 continue 

94 

95 self._update_progress("Recommendations complete", 100) 

96 

97 # Sort by relevance 

98 recommendations = self._sort_by_relevance(recommendations, user_id) 

99 

100 logger.info( 

101 f"Generated {len(recommendations)} recommendations for user {user_id}" 

102 ) 

103 

104 except Exception as e: 

105 logger.exception("Error generating recommendations") 

106 self._update_progress(f"Error: {str(e)}", 100) 

107 

108 return recommendations 

109 

110 def _get_trending_topics( 

111 self, context: Optional[Dict[str, Any]] 

112 ) -> List[str]: 

113 """ 

114 Get trending topics to recommend. 

115 

116 Args: 

117 context: Optional context 

118 

119 Returns: 

120 List of trending topic strings 

121 """ 

122 topics = [] 

123 

124 # Get from topic registry if available 

125 if self.topic_registry: 

126 topics.extend( 

127 self.topic_registry.get_trending_topics(hours=24, limit=20) 

128 ) 

129 

130 # Add context-based topics if provided 

131 if context: 

132 if "current_news_topics" in context: 

133 topics.extend(context["current_news_topics"]) 

134 if "current_category" in context: 

135 # Could fetch related topics based on category 

136 pass 

137 

138 # Fallback topics if none found 

139 if not topics: 

140 logger.warning("No trending topics found, using defaults") 

141 topics = [ 

142 "artificial intelligence developments", 

143 "cybersecurity threats", 

144 "climate change", 

145 "economic policy", 

146 "technology innovation", 

147 ] 

148 

149 return topics 

150 

151 def _filter_topics_by_preferences( 

152 self, topics: List[str], preferences: Dict[str, Any] 

153 ) -> List[str]: 

154 """ 

155 Filter topics based on user preferences. 

156 

157 Args: 

158 topics: List of topics to filter 

159 preferences: User preferences 

160 

161 Returns: 

162 Filtered list of topics 

163 """ 

164 filtered = [] 

165 

166 # Get preference lists 

167 disliked_topics = [ 

168 t.lower() for t in preferences.get("disliked_topics", []) 

169 ] 

170 interests = preferences.get("interests", {}) 

171 

172 for topic in topics: 

173 topic_lower = topic.lower() 

174 

175 # Skip disliked topics 

176 if any(disliked in topic_lower for disliked in disliked_topics): 

177 continue 

178 

179 # Boost topics matching interests 

180 boost = 1.0 

181 for interest, weight in interests.items(): 

182 if interest.lower() in topic_lower: 

183 boost = weight 

184 break 

185 

186 # Add with boost information 

187 filtered.append((topic, boost)) 

188 

189 # Sort by boost (highest first) 

190 filtered.sort(key=lambda x: x[1], reverse=True) 

191 

192 # Return just the topics 

193 return [topic for topic, _ in filtered] 

194 

195 def _generate_topic_query(self, topic: str) -> str: 

196 """ 

197 Generate a search query for a topic. 

198 

199 Args: 

200 topic: The topic to search for 

201 

202 Returns: 

203 Search query string 

204 """ 

205 # Add news-specific context 

206 return f"{topic} latest news today breaking developments" 

207 

208 def _create_recommendation_card( 

209 self, topic: str, query: str, user_id: str 

210 ) -> Optional[NewsCard]: 

211 """ 

212 Create a news card from a topic recommendation. 

213 

214 Args: 

215 topic: The topic 

216 query: The search query used 

217 user_id: The user ID 

218 

219 Returns: 

220 NewsCard or None if search fails 

221 """ 

222 llm = None 

223 search = None 

224 search_system = None 

225 try: 

226 # Use news search strategy 

227 from ...config.llm_config import get_llm 

228 from ...config.search_config import get_search 

229 

230 try: 

231 llm = get_llm() 

232 except ValueError: 

233 # Configuration not set (e.g. llm.model empty). User issue, 

234 # not a runtime fault. Log a single concise warning per 

235 # scheduled topic — no stack trace, since this scheduler 

236 # runs repeatedly and we don't want to spam the log. 

237 # Topic is externally derived (news content); sanitize 

238 # before interpolation to prevent log injection. 

239 logger.warning( 

240 f"Skipping news recommendation for topic " 

241 f"'{sanitize_for_log(topic)}': " 

242 "LLM not configured. Set llm.model in Settings." 

243 ) 

244 return None 

245 except PolicyDeniedError as exc: 

246 # The egress policy refused the LLM (no snapshot in this 

247 # background path + non-local provider). Recurring 

248 # scheduler — log concisely, do not retry. 

249 # Decision.reason is a short machine code, safe to log. 

250 denial_reason = exc.decision.reason 

251 logger.warning( 

252 f"Skipping news recommendation for topic " 

253 f"'{sanitize_for_log(topic)}': " 

254 f"LLM blocked by egress policy ({denial_reason}). " 

255 "Use a local provider (ollama/lmstudio/llamacpp) or " 

256 "thread settings_snapshot through this caller." 

257 ) 

258 return None 

259 search = get_search(llm_instance=llm) 

260 search_system = AdvancedSearchSystem( 

261 llm=llm, search=search, strategy_name="news" 

262 ) 

263 

264 # Mark as news search to use priority system 

265 results = search_system.analyze_topic(query, is_news_search=True) 

266 

267 if "error" in results: 

268 logger.error( 

269 f"Search failed for topic '{sanitize_for_log(topic)}': " 

270 f"{sanitize_for_log(str(results['error']))}" 

271 ) 

272 return None 

273 

274 # Check if we have news items directly from the search 

275 news_items = results.get("news_items", []) 

276 

277 # Use the news items from search results 

278 news_data = { 

279 "items": news_items, 

280 "item_count": len(news_items), 

281 "big_picture": results.get("formatted_findings", ""), 

282 "topics": [], 

283 } 

284 

285 if not news_items: 

286 logger.warning( 

287 f"No news items found for topic '{sanitize_for_log(topic)}'" 

288 ) 

289 return None 

290 

291 # Create card using factory 

292 # Use the most impactful news item as the main content 

293 main_item = max(news_items, key=lambda x: x.get("impact_score", 0)) 

294 

295 card = CardFactory.create_news_card_from_analysis( 

296 news_item=main_item, 

297 source_search_id=str(results.get("search_id") or ""), 

298 user_id=user_id, 

299 additional_metadata={ 

300 "recommender": self.strategy_name, 

301 "original_topic": topic, 

302 "query_used": query, 

303 "total_items_found": len(news_items), 

304 "big_picture": news_data.get("big_picture", ""), 

305 "topics_extracted": news_data.get("topics", []), 

306 }, 

307 ) 

308 

309 # Add the full analysis as the first version 

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

311 card.add_version( 

312 research_results={ 

313 "search_results": results, 

314 "news_analysis": news_data, 

315 "query": query, 

316 "strategy": "news_aggregation", 

317 }, 

318 query=query, 

319 strategy="news_aggregation", 

320 ) 

321 

322 return card 

323 

324 except Exception: 

325 logger.exception( 

326 f"Error creating recommendation card for topic " 

327 f"'{sanitize_for_log(topic)}'" 

328 ) 

329 return None 

330 finally: 

331 from ...utilities.resource_utils import safe_close 

332 

333 safe_close(search_system, "news search system", allow_none=True) 

334 safe_close(search, "news search engine", allow_none=True) 

335 safe_close(llm, "news LLM", allow_none=True) 

336 

337 

338class SearchBasedRecommender(BaseRecommender): 

339 """ 

340 Recommends news based on user's recent searches. 

341 Only works if search tracking is enabled. 

342 """ 

343 

344 def generate_recommendations( 

345 self, user_id: str, context: Optional[Dict[str, Any]] = None 

346 ) -> List[NewsCard]: 

347 """ 

348 Generate recommendations from user's search history. 

349 

350 Args: 

351 user_id: User to generate recommendations for 

352 context: Optional context 

353 

354 Returns: 

355 List of NewsCard recommendations 

356 """ 

357 logger.info( 

358 f"Generating search-based recommendations for user {user_id}" 

359 ) 

360 

361 # This would need access to search history 

362 # For now, return empty since search tracking is OFF by default 

363 logger.warning( 

364 "Search-based recommendations not available - search tracking is disabled" 

365 ) 

366 return [] 

367 

368 # Future implementation would: 

369 # 1. Get user's recent searches 

370 # 2. Transform them to news queries 

371 # 3. Create recommendation cards