Coverage for src/local_deep_research/news/api.py: 90%

418 statements  

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

1""" 

2Direct API functions for news system. 

3These functions can be called directly by scheduler or wrapped by Flask endpoints. 

4""" 

5 

6from typing import Dict, Any, Optional 

7from datetime import datetime, timezone, UTC 

8from loguru import logger 

9from sqlalchemy.orm import Session 

10import json 

11 

12from ..constants import ResearchStatus 

13from ..llm.providers.base import normalize_provider 

14from ..utilities.sql_utils import escape_like 

15from .exceptions import ( 

16 InvalidLimitException, 

17 SubscriptionNotFoundException, 

18 SubscriptionCreationException, 

19 SubscriptionUpdateException, 

20 SubscriptionDeletionException, 

21 DatabaseAccessException, 

22 NewsFeedGenerationException, 

23 NotImplementedException, 

24 NewsAPIException, 

25) 

26from ..constants import DEFAULT_SEARCH_TOOL 

27# Removed welcome feed import - no placeholders 

28# get_db_setting not available in merged codebase 

29 

30# Generic detail surfaced to API clients in place of a raw exception string. 

31# These messages are returned to the client via NewsAPIException.to_dict(), so 

32# echoing str(e) would leak DB/driver internals (SQL, schema, paths) to callers 

33# on shared/multi-user deployments (CWE-209). The real cause is always captured 

34# server-side by the adjacent logger.exception(...) for diagnosis. 

35_GENERIC_ERROR_DETAIL = "an internal error occurred" 

36 

37 

38def _notify_scheduler_about_subscription_change( 

39 action: str, user_id: Optional[str] = None 

40): 

41 """ 

42 Notify the scheduler about subscription changes. 

43 

44 Args: 

45 action: The action performed (created, updated, deleted) 

46 user_id: Optional user_id to use as fallback for username 

47 """ 

48 try: 

49 from flask import session as flask_session 

50 from ..scheduler.background import get_background_job_scheduler 

51 

52 scheduler = get_background_job_scheduler() 

53 if scheduler.is_running: 

54 # Get username, with optional fallback to user_id 

55 username = flask_session.get("username") 

56 if not username and user_id: 

57 username = user_id 

58 

59 # Get password from session password store 

60 from ..database.session_passwords import session_password_store 

61 

62 session_id = flask_session.get("session_id") 

63 password = None 

64 if session_id and username: 64 ↛ 69line 64 didn't jump to line 69 because the condition on line 64 was always true

65 password = session_password_store.get_session_password( 

66 username, session_id 

67 ) 

68 

69 if password and username: 

70 # Update scheduler to reschedule subscriptions 

71 scheduler.update_user_info(username, password) 

72 logger.info( 

73 f"Scheduler notified about {action} subscription for {username}" 

74 ) 

75 else: 

76 logger.warning( 

77 f"Could not notify scheduler - no password available{' for ' + username if username else ''}" 

78 ) 

79 except Exception: 

80 logger.exception( 

81 f"Could not notify scheduler about {action} subscription" 

82 ) 

83 

84 

85def get_news_feed( 

86 user_id: str = "anonymous", 

87 limit: int = 20, 

88 use_cache: bool = True, 

89 focus: Optional[str] = None, 

90 search_strategy: Optional[str] = None, 

91 subscription_id: Optional[str] = None, 

92) -> Dict[str, Any]: 

93 """ 

94 Get personalized news feed by pulling from news_items table first, then research history. 

95 

96 Args: 

97 user_id: User identifier 

98 limit: Maximum number of cards to return 

99 use_cache: Whether to use cached news 

100 focus: Optional focus area for news 

101 search_strategy: Override default recommendation strategy 

102 

103 Returns: 

104 Dictionary with news items and metadata. Each item's ``findings`` 

105 field is the answer-only report content (post chat-mode-v2 refactor, 

106 #3665 Fix B); structured top-N source links are in the separate 

107 ``links`` array, not embedded in ``findings``. 

108 """ 

109 # Validate limit - allow any positive number 

110 if limit < 1: 

111 raise InvalidLimitException(limit) 

112 

113 try: 

114 logger.info( 

115 f"get_news_feed called with user_id={user_id}, limit={limit}" 

116 ) 

117 

118 # News is always enabled for now - per-user settings will be handled later 

119 # if not get_db_setting("news.enabled", True): 

120 # return {"error": "News system is disabled", "news_items": []} 

121 

122 # Import database functions 

123 from ..database.session_context import get_user_db_session 

124 from ..database.models import ResearchHistory 

125 

126 news_items = [] 

127 remaining_limit = limit 

128 

129 # Query research history from user's database for news items 

130 logger.info("Getting news items from research history") 

131 try: 

132 # Use the user_id provided to the function 

133 with get_user_db_session(user_id) as db_session: 

134 # Build query using ORM 

135 query = db_session.query(ResearchHistory).filter( 

136 ResearchHistory.status == ResearchStatus.COMPLETED 

137 ) 

138 

139 # Filter by subscription if provided 

140 if subscription_id and subscription_id != "all": 

141 # Use JSON containment for PostgreSQL or LIKE for SQLite. 

142 # Note: research_meta is serialized via json.dumps which 

143 # emits a space after the colon, so the LIKE pattern must 

144 # include that space too — otherwise the filter silently 

145 # matches zero rows. Mirrors the patterns used in 

146 # get_subscriptions and get_subscription_history below. 

147 query = query.filter( 

148 ResearchHistory.research_meta.like( 

149 f'%"subscription_id": "{escape_like(subscription_id)}"%', 

150 escape="\\", 

151 ) 

152 ) 

153 

154 # Order by creation date and limit 

155 results = ( 

156 query.order_by(ResearchHistory.created_at.desc()) 

157 .limit(remaining_limit * 2) 

158 .all() 

159 ) 

160 

161 # Convert ORM objects to dictionaries for compatibility 

162 results = [ 

163 { 

164 "id": r.id, 

165 "uuid_id": r.id, # In ResearchHistory, id is the UUID 

166 "query": r.query, 

167 "title": r.title 

168 if hasattr(r, "title") 

169 else None, # Include title field if exists 

170 # created_at is NOT NULL (set to isoformat() on every 

171 # insert), so it's always a usable timestamp string. 

172 "created_at": r.created_at, 

173 "completed_at": r.completed_at 

174 if r.completed_at 

175 else None, 

176 "duration_seconds": r.duration_seconds 

177 if hasattr(r, "duration_seconds") 

178 else None, 

179 "report_path": r.report_path 

180 if hasattr(r, "report_path") 

181 else None, 

182 "report_content": r.report_content 

183 if hasattr(r, "report_content") 

184 else None, # Include database content 

185 "research_meta": r.research_meta, 

186 "status": r.status, 

187 } 

188 for r in results 

189 ] 

190 

191 # Source links used to be parsed out of report_content via 

192 # regex over `URL:` lines (when report_content held the 

193 # inline ## Sources block). Now sources live in the 

194 # research_resources table — fetch top-N for every row in 

195 # ONE batched query (avoids N+1 in the loop below). 

196 from ..web.services.report_assembly_service import ( 

197 get_research_source_links_batch, 

198 ) 

199 

200 research_ids_for_links = [r["id"] for r in results] 

201 links_by_research_id = get_research_source_links_batch( 

202 research_ids_for_links, db_session, limit=3 

203 ) 

204 

205 logger.info(f"Database returned {len(results)} research items") 

206 if results and len(results) > 0: 

207 logger.info(f"First row keys: {list(results[0].keys())}") 

208 # Log first few items' metadata 

209 for i, row in enumerate(results[:3]): 

210 logger.info( 

211 f"Item {i}: query='{row['query'][:50]}...', has meta={bool(row.get('research_meta'))}" 

212 ) 

213 

214 # Process results to find news items 

215 processed_count = 0 

216 error_count = 0 

217 

218 for row in results: 

219 try: 

220 # Parse metadata 

221 metadata = {} 

222 if row.get("research_meta"): 

223 try: 

224 # Handle both dict and string formats 

225 if isinstance(row["research_meta"], dict): 

226 metadata = row["research_meta"] 

227 else: 

228 metadata = json.loads(row["research_meta"]) 

229 except (json.JSONDecodeError, TypeError): 

230 logger.exception("Error parsing metadata") 

231 metadata = {} 

232 

233 # Check if this has news metadata (generated_headline or generated_topics) 

234 # or if it's a news-related query 

235 has_news_metadata = ( 

236 metadata.get("generated_headline") is not None 

237 or metadata.get("generated_topics") is not None 

238 ) 

239 

240 query_lower = row["query"].lower() 

241 is_news_query = ( 

242 has_news_metadata 

243 or metadata.get("is_news_search") 

244 or metadata.get("search_type") == "news_analysis" 

245 or "breaking news" in query_lower 

246 or "news stories" in query_lower 

247 or ( 

248 "today" in query_lower 

249 and ( 

250 "news" in query_lower 

251 or "breaking" in query_lower 

252 ) 

253 ) 

254 or "latest news" in query_lower 

255 ) 

256 

257 # Log the decision for first few items 

258 if processed_count < 3 or error_count < 3: 258 ↛ 265line 258 didn't jump to line 265 because the condition on line 258 was always true

259 logger.info( 

260 f"Item check: query='{row['query'][:30]}...', is_news_search={metadata.get('is_news_search')}, " 

261 f"has_news_metadata={has_news_metadata}, is_news_query={is_news_query}" 

262 ) 

263 

264 # Only show items that have news metadata or are news queries 

265 if is_news_query: 

266 processed_count += 1 

267 logger.info( 

268 f"Processing research item #{processed_count}: {row['query'][:50]}..." 

269 ) 

270 

271 # Always use database content 

272 findings = "" 

273 summary = "" 

274 report_content_db = row.get( 

275 "report_content" 

276 ) # Get database content 

277 

278 # Use database content 

279 content = report_content_db 

280 if content: 

281 logger.debug( 

282 f"Using database content for research {row['id']}" 

283 ) 

284 

285 # Process database content 

286 lines = content.split("\n") if content else [] 

287 # `findings` is the answer-only report_content 

288 # after the chat-mode-v2 refactor (#3665 Fix B, 

289 # intentional). The legacy answer + ## Sources 

290 # blob is gone: structured top-N source URLs live 

291 # in the separate `links` array below, so snippet 

292 # extraction is cleaner without Sources headers in 

293 # the substrate. 

294 findings = content 

295 # Extract summary from first non-empty line 

296 for line in lines: 296 ↛ 306line 296 didn't jump to line 306 because the loop on line 296 didn't complete

297 if line.strip() and not line.startswith("#"): 

298 summary = line.strip() 

299 break 

300 else: 

301 logger.debug( 

302 f"No database content for research {row['id']}" 

303 ) 

304 

305 # Use stored headline/topics if available, otherwise generate 

306 original_query = row["query"] 

307 

308 # Check for headline - first try database title, then metadata 

309 headline = row.get("title") or metadata.get( 

310 "generated_headline" 

311 ) 

312 

313 # For subscription results, generate headline from query if needed 

314 if not headline and metadata.get("is_news_search"): 

315 # Use subscription name or query as headline 

316 subscription_name = metadata.get( 

317 "subscription_name" 

318 ) 

319 if subscription_name: 

320 headline = f"News Update: {subscription_name}" 

321 else: 

322 # Generate headline from query 

323 headline = f"News: {row['query'][:60]}..." 

324 

325 # Skip items without meaningful headlines or that are incomplete 

326 if ( 

327 not headline 

328 or headline == "[No headline available]" 

329 ): 

330 logger.debug( 

331 f"Skipping item without headline: {row['id']}" 

332 ) 

333 continue 

334 

335 # Skip items that are still in progress or suspended 

336 if row["status"] in ( 

337 ResearchStatus.IN_PROGRESS, 

338 ResearchStatus.SUSPENDED, 

339 ): 

340 logger.debug( 

341 f"Skipping incomplete item: {row['id']} (status: {row['status']})" 

342 ) 

343 continue 

344 

345 # Skip items without content (neither file nor database) 

346 if not content: 

347 logger.debug( 

348 f"Skipping item without content: {row['id']}" 

349 ) 

350 continue 

351 

352 # Use ID properly, preferring uuid_id 

353 research_id = row.get("uuid_id") or str(row["id"]) 

354 

355 # Use stored category and topics - no defaults 

356 category = metadata.get("category") 

357 if not category: 357 ↛ 360line 357 didn't jump to line 360 because the condition on line 357 was always true

358 category = "[Uncategorized]" 

359 

360 topics = metadata.get("generated_topics") 

361 if not topics: 361 ↛ 367line 361 didn't jump to line 367 because the condition on line 361 was always true

362 topics = ["[No topics]"] 

363 

364 # Top-N links pulled from research_resources via 

365 # the batch fetch above (no per-row DB query, no 

366 # text parsing of report_content). 

367 links = links_by_research_id.get(row["id"], []) 

368 

369 # Create news item from research 

370 news_item = { 

371 "id": f"news-{research_id}", 

372 "headline": headline, 

373 "category": category, 

374 "summary": summary 

375 or f"Research analysis for: {headline[:100]}", 

376 "findings": findings, 

377 "impact_score": metadata.get( 

378 "impact_score", 0 

379 ), # 0 indicates missing 

380 "time_ago": _format_time_ago(row["created_at"]), 

381 "upvotes": metadata.get("upvotes", 0), 

382 "downvotes": metadata.get("downvotes", 0), 

383 "source_url": f"/results/{research_id}", 

384 "topics": topics, # Use generated topics 

385 "links": links, # Add extracted links 

386 "research_id": research_id, 

387 "created_at": row["created_at"], 

388 "duration_seconds": row.get("duration_seconds", 0), 

389 "original_query": original_query, # Keep original query for reference 

390 "is_news": metadata.get( 

391 "is_news_search", False 

392 ), # Flag for news searches 

393 "news_date": metadata.get( 

394 "news_date" 

395 ), # If specific date for news 

396 "news_source": metadata.get( 

397 "news_source" 

398 ), # If from specific source 

399 "priority": metadata.get( 

400 "priority", "normal" 

401 ), # Priority level 

402 } 

403 

404 news_items.append(news_item) 

405 logger.info(f"Added news item: {headline[:50]}...") 

406 

407 if len(news_items) >= limit: 407 ↛ 408line 407 didn't jump to line 408 because the condition on line 407 was never true

408 break 

409 

410 except Exception: 

411 error_count += 1 

412 logger.exception( 

413 f"Error processing research item with query: {row.get('query', 'UNKNOWN')[:100]}" 

414 ) 

415 continue 

416 

417 logger.info( 

418 f"Processing summary: total_results={len(results)}, processed={processed_count}, " 

419 f"errors={error_count}, added={len(news_items)}" 

420 ) 

421 

422 # Log subscription-specific items if we were filtering 

423 if subscription_id and subscription_id != "all": 

424 sub_items = [ 

425 item for item in news_items if item.get("is_news", False) 

426 ] 

427 logger.info( 

428 f"Subscription {subscription_id}: found {len(sub_items)} items" 

429 ) 

430 

431 except Exception as db_error: 

432 logger.exception(f"Database error in research history: {db_error}") 

433 raise DatabaseAccessException( 

434 "research_history_query", _GENERIC_ERROR_DETAIL 

435 ) 

436 

437 # If no news items found, return empty list 

438 if not news_items: 

439 logger.info("No news items found, returning empty list") 

440 news_items = [] 

441 

442 logger.info(f"Returning {len(news_items)} news items to client") 

443 

444 # Determine the source 

445 source = ( 

446 "news_items" 

447 if any(item.get("is_news", False) for item in news_items) 

448 else "research_history" 

449 ) 

450 

451 return { 

452 "news_items": news_items[:limit], 

453 "generated_at": datetime.now(timezone.utc).isoformat(), 

454 "focus": focus, 

455 "search_strategy": search_strategy or "default", 

456 "total_items": len(news_items), 

457 "source": source, 

458 } 

459 

460 except NewsAPIException: 

461 # Re-raise our custom exceptions 

462 raise 

463 except Exception: 

464 logger.exception("Error getting news feed") 

465 raise NewsFeedGenerationException( 

466 _GENERIC_ERROR_DETAIL, user_id=user_id 

467 ) 

468 

469 

470def get_subscription_history( 

471 subscription_id: str, limit: int = 20 

472) -> Dict[str, Any]: 

473 """ 

474 Get research history for a specific subscription. 

475 

476 Args: 

477 subscription_id: The subscription UUID 

478 limit: Maximum number of history items to return 

479 

480 Returns: 

481 Dict containing subscription info and its research history 

482 """ 

483 try: 

484 from ..database.session_context import get_user_db_session 

485 from ..database.models import ResearchHistory 

486 from ..database.models.news import NewsSubscription 

487 

488 # Get subscription details using ORM from user's encrypted database 

489 with get_user_db_session() as session: 

490 subscription = ( 

491 session.query(NewsSubscription) 

492 .filter_by(id=subscription_id) 

493 .first() 

494 ) 

495 

496 if not subscription: 

497 raise SubscriptionNotFoundException(subscription_id) # noqa: TRY301 — re-raised by except NewsAPIException 

498 

499 # Convert to dict for response 

500 subscription_dict = { 

501 "id": subscription.id, 

502 "query_or_topic": subscription.query_or_topic, 

503 "subscription_type": subscription.subscription_type, 

504 "refresh_interval_minutes": subscription.refresh_interval_minutes, 

505 # refresh_count is derived from the actual research-run history 

506 # below (NewsSubscription has no refresh_count column — reading 

507 # it raised AttributeError and 500'd this endpoint). 

508 "created_at": subscription.created_at.isoformat() 

509 if subscription.created_at 

510 else None, 

511 "next_refresh": subscription.next_refresh.isoformat() 

512 if subscription.next_refresh 

513 else None, 

514 } 

515 

516 # Now get research history from the research database. The 

517 # NewsSubscription model has no user_id column — this codebase uses 

518 # per-user encrypted databases, so "the subscription's user" is just 

519 # whichever user's DB we found the subscription in. Reuse the Flask 

520 # session username (same source the first get_user_db_session() 

521 # call resolved). The previous version of this code did 

522 # ``subscription_dict.get("user_id", "anonymous")`` against a dict 

523 # that never carried a "user_id" key, so it always opened the 

524 # "anonymous" user's database and silently returned an empty 

525 # history for every real multi-user deployment. 

526 with get_user_db_session() as db_session: 

527 # Get all research runs that were triggered by this subscription 

528 # Look for subscription_id in the research_meta JSON 

529 # Note: JSON format has space after colon 

530 like_pattern = ( 

531 f'%"subscription_id": "{escape_like(subscription_id)}"%' 

532 ) 

533 logger.info( 

534 f"Searching for research history with pattern: {like_pattern}" 

535 ) 

536 

537 history_items = ( 

538 db_session.query(ResearchHistory) 

539 .filter( 

540 ResearchHistory.research_meta.like( 

541 like_pattern, escape="\\" 

542 ) 

543 ) 

544 .order_by(ResearchHistory.created_at.desc()) 

545 .limit(limit) 

546 .all() 

547 ) 

548 

549 # Convert to dict format for compatibility. 

550 # ResearchHistory.id is the UUID PK (see comment on line 151); 

551 # there is no separate uuid_id column, so populate both keys 

552 # from h.id to preserve the downstream contract used by the 

553 # processed_item['research_id']/url builders below. 

554 history_items = [ 

555 { 

556 "id": h.id, 

557 "uuid_id": h.id, 

558 "query": h.query, 

559 "status": h.status, 

560 # created_at/completed_at are Text columns already holding 

561 # isoformat strings (see get_news_feed above), so use them 

562 # directly — calling .isoformat() on a str raises 

563 # AttributeError and previously broke this endpoint for any 

564 # subscription that had research history. 

565 "created_at": h.created_at, 

566 "completed_at": h.completed_at if h.completed_at else None, 

567 "duration_seconds": h.duration_seconds, 

568 "research_meta": h.research_meta, 

569 "report_path": h.report_path, 

570 } 

571 for h in history_items 

572 ] 

573 

574 # Process history items 

575 processed_history = [] 

576 for item in history_items: 

577 processed_item = { 

578 "research_id": item.get("uuid_id") or str(item.get("id")), 

579 "query": item["query"], 

580 "status": item["status"], 

581 "created_at": item["created_at"], 

582 "completed_at": item.get("completed_at"), 

583 "duration_seconds": item.get("duration_seconds", 0), 

584 "url": f"/progress/{item.get('uuid_id') or item.get('id')}", 

585 } 

586 

587 # Parse metadata if available to get headline and topics 

588 if item.get("research_meta"): 

589 try: 

590 # research_meta is a JSON column, so SQLAlchemy already 

591 # deserializes it to a dict on read; only legacy/text rows 

592 # arrive as a JSON string. Calling json.loads() on the dict 

593 # raised TypeError that the bare except below swallowed, 

594 # silently blanking the headline/topics for every item. 

595 # Mirror the dict/str handling already used at the top of 

596 # this module (see get_news_feed). 

597 raw_meta = item["research_meta"] 

598 meta = ( 

599 json.loads(raw_meta) 

600 if isinstance(raw_meta, str) 

601 else raw_meta 

602 ) 

603 processed_item["triggered_by"] = meta.get( 

604 "triggered_by", "subscription" 

605 ) 

606 # Add headline and topics from metadata 

607 processed_item["headline"] = meta.get( 

608 "generated_headline", "[No headline]" 

609 ) 

610 processed_item["topics"] = meta.get("generated_topics", []) 

611 except Exception: 

612 processed_item["headline"] = "[No headline]" 

613 processed_item["topics"] = [] 

614 else: 

615 processed_item["headline"] = "[No headline]" 

616 processed_item["topics"] = [] 

617 

618 processed_history.append(processed_item) 

619 

620 # Run count comes from the research history (the source of truth); 

621 # the subscription row carries no counter. 

622 subscription_dict["refresh_count"] = len(processed_history) 

623 

624 return { 

625 "subscription": subscription_dict, 

626 "history": processed_history, 

627 "total_runs": len(processed_history), 

628 } 

629 

630 except NewsAPIException: 

631 # Re-raise our custom exceptions 

632 raise 

633 except Exception: 

634 logger.exception("Error getting subscription history") 

635 raise DatabaseAccessException( 

636 "get_subscription_history", _GENERIC_ERROR_DETAIL 

637 ) 

638 

639 

640def _format_time_ago(timestamp: str) -> str: 

641 """Format timestamp as 'X hours ago' string. 

642 

643 Raises on unparseable input instead of masking it with a neutral label. 

644 ResearchHistory.created_at is a NOT NULL column written as 

645 datetime.now(UTC).isoformat() on every insert path, so a value that won't 

646 parse means the row is corrupt — not a routine edge case. The only caller 

647 (the per-row loop in get_news_feed) already wraps each row in a 

648 try/except that logs the failure and skips the row, so a bad timestamp 

649 surfaces in the logs and drops that one card rather than rendering it with 

650 a misleading "Recently". 

651 """ 

652 from dateutil import parser 

653 

654 dt = parser.parse(timestamp) 

655 

656 # If dt is naive, assume it's in UTC 

657 if dt.tzinfo is None: 

658 dt = dt.replace(tzinfo=timezone.utc) 

659 

660 now = datetime.now(timezone.utc) 

661 diff = now - dt 

662 

663 if diff.days > 0: 

664 return f"{diff.days} day{'s' if diff.days > 1 else ''} ago" 

665 if diff.seconds > 3600: 

666 hours = diff.seconds // 3600 

667 return f"{hours} hour{'s' if hours > 1 else ''} ago" 

668 if diff.seconds > 60: 

669 minutes = diff.seconds // 60 

670 return f"{minutes} minute{'s' if minutes > 1 else ''} ago" 

671 return "Just now" 

672 

673 

674def get_subscription(subscription_id: str) -> Optional[Dict[str, Any]]: 

675 """ 

676 Get a single subscription by ID. 

677 

678 Args: 

679 subscription_id: Subscription identifier 

680 

681 Returns: 

682 Dictionary with subscription data or None if not found 

683 """ 

684 try: 

685 # Get subscription directly from user's encrypted database 

686 from ..database.session_context import get_user_db_session 

687 from ..database.models.news import NewsSubscription 

688 

689 with get_user_db_session() as db_session: 

690 subscription = ( 

691 db_session.query(NewsSubscription) 

692 .filter_by(id=subscription_id) 

693 .first() 

694 ) 

695 

696 if not subscription: 

697 raise SubscriptionNotFoundException(subscription_id) # noqa: TRY301 — re-raised by except NewsAPIException 

698 

699 # Convert to API format matching the template expectations 

700 return { 

701 "id": subscription.id, 

702 "name": subscription.name or "", 

703 "query_or_topic": subscription.query_or_topic, 

704 "subscription_type": subscription.subscription_type, 

705 "refresh_interval_minutes": subscription.refresh_interval_minutes, 

706 "is_active": subscription.status == "active", 

707 "status": subscription.status, 

708 "folder_id": subscription.folder_id, 

709 "model_provider": subscription.model_provider, 

710 "model": subscription.model, 

711 "search_strategy": subscription.search_strategy, 

712 "custom_endpoint": subscription.custom_endpoint, 

713 "search_engine": subscription.search_engine, 

714 "search_iterations": subscription.search_iterations or 3, 

715 "questions_per_iteration": subscription.questions_per_iteration 

716 or 5, 

717 "created_at": subscription.created_at.isoformat() 

718 if subscription.created_at 

719 else None, 

720 "updated_at": subscription.updated_at.isoformat() 

721 if subscription.updated_at 

722 else None, 

723 } 

724 

725 except NewsAPIException: 

726 # Re-raise our custom exceptions 

727 raise 

728 except Exception: 

729 logger.exception(f"Error getting subscription {subscription_id}") 

730 raise DatabaseAccessException("get_subscription", _GENERIC_ERROR_DETAIL) 

731 

732 

733def get_subscriptions(user_id: str) -> Dict[str, Any]: 

734 """ 

735 Get all subscriptions for a user. 

736 

737 Args: 

738 user_id: User identifier 

739 

740 Returns: 

741 Dictionary with subscriptions list 

742 """ 

743 try: 

744 # Get subscriptions directly from user's encrypted database 

745 from ..database.session_context import get_user_db_session 

746 from ..database.models import ResearchHistory 

747 from ..database.models.news import NewsSubscription 

748 from sqlalchemy import func 

749 

750 sub_list = [] 

751 

752 with get_user_db_session(user_id) as db_session: 

753 # Query all subscriptions for this user 

754 subscriptions = db_session.query(NewsSubscription).all() 

755 

756 for sub in subscriptions: 

757 # Count actual research runs for this subscription. 

758 # sub.id is an internal PK (not user input), but escape it 

759 # too so every subscription_id LIKE in this module is 

760 # uniformly wildcard-safe — a no-op for UUID-shaped ids. 

761 like_pattern = f'%"subscription_id": "{escape_like(sub.id)}"%' 

762 total_runs = ( 

763 db_session.query(func.count(ResearchHistory.id)) 

764 .filter( 

765 ResearchHistory.research_meta.like( 

766 like_pattern, escape="\\" 

767 ) 

768 ) 

769 .scalar() 

770 or 0 

771 ) 

772 

773 # Convert ORM object to API format 

774 sub_dict = { 

775 "id": sub.id, 

776 "query": sub.query_or_topic, 

777 "type": sub.subscription_type, 

778 "refresh_minutes": sub.refresh_interval_minutes, 

779 "created_at": sub.created_at.isoformat() 

780 if sub.created_at 

781 else None, 

782 "next_refresh": sub.next_refresh.isoformat() 

783 if sub.next_refresh 

784 else None, 

785 "last_refreshed": sub.last_refresh.isoformat() 

786 if sub.last_refresh 

787 else None, 

788 "is_active": sub.status == "active", 

789 "status": sub.status, 

790 "total_runs": total_runs, # Use actual count from research_history 

791 "name": sub.name or "", 

792 "folder_id": sub.folder_id, 

793 # Model/search config: included so consumers (the 

794 # subscriptions UI, run-now callers) see the subscription's 

795 # saved configuration instead of silently getting None and 

796 # falling back to global defaults. 

797 "model_provider": sub.model_provider, 

798 "model": sub.model, 

799 "search_strategy": sub.search_strategy, 

800 "search_engine": sub.search_engine, 

801 "custom_endpoint": sub.custom_endpoint, 

802 "search_iterations": sub.search_iterations, 

803 "questions_per_iteration": sub.questions_per_iteration, 

804 } 

805 sub_list.append(sub_dict) 

806 

807 return {"subscriptions": sub_list, "total": len(sub_list)} 

808 

809 except Exception: 

810 logger.exception("Error getting subscriptions") 

811 raise DatabaseAccessException( 

812 "get_subscriptions", _GENERIC_ERROR_DETAIL 

813 ) 

814 

815 

816def update_subscription( 

817 subscription_id: str, data: Dict[str, Any] 

818) -> Dict[str, Any]: 

819 """ 

820 Update an existing subscription. 

821 

822 Args: 

823 subscription_id: Subscription identifier 

824 data: Dictionary with fields to update 

825 

826 Returns: 

827 Dictionary with updated subscription data 

828 """ 

829 try: 

830 from ..database.session_context import get_user_db_session 

831 from ..database.models.news import NewsSubscription 

832 from datetime import datetime, timedelta 

833 

834 with get_user_db_session() as db_session: 

835 # Get existing subscription 

836 subscription = ( 

837 db_session.query(NewsSubscription) 

838 .filter_by(id=subscription_id) 

839 .first() 

840 ) 

841 if not subscription: 

842 raise SubscriptionNotFoundException(subscription_id) # noqa: TRY301 — re-raised by except NewsAPIException 

843 

844 # Update fields 

845 if "name" in data: 

846 subscription.name = data["name"] 

847 if "query_or_topic" in data: 

848 subscription.query_or_topic = data["query_or_topic"] 

849 if "subscription_type" in data: 849 ↛ 850line 849 didn't jump to line 850 because the condition on line 849 was never true

850 subscription.subscription_type = data["subscription_type"] 

851 if "refresh_interval_minutes" in data: 

852 old_interval = subscription.refresh_interval_minutes 

853 subscription.refresh_interval_minutes = data[ 

854 "refresh_interval_minutes" 

855 ] 

856 # Recalculate next_refresh if interval changed 

857 if old_interval != subscription.refresh_interval_minutes: 857 ↛ 861line 857 didn't jump to line 861 because the condition on line 857 was always true

858 subscription.next_refresh = datetime.now(UTC) + timedelta( 

859 minutes=subscription.refresh_interval_minutes 

860 ) 

861 if "is_active" in data: 

862 subscription.status = ( 

863 "active" if data["is_active"] else "paused" 

864 ) 

865 if "status" in data: 865 ↛ 866line 865 didn't jump to line 866 because the condition on line 865 was never true

866 subscription.status = data["status"] 

867 if "folder_id" in data: 

868 subscription.folder_id = data["folder_id"] 

869 if "model_provider" in data: 

870 subscription.model_provider = normalize_provider( 

871 data["model_provider"] 

872 ) 

873 if "model" in data: 

874 subscription.model = data["model"] 

875 if "search_strategy" in data: 875 ↛ 876line 875 didn't jump to line 876 because the condition on line 875 was never true

876 subscription.search_strategy = data["search_strategy"] 

877 if "custom_endpoint" in data: 877 ↛ 878line 877 didn't jump to line 878 because the condition on line 877 was never true

878 subscription.custom_endpoint = data["custom_endpoint"] 

879 if "search_engine" in data: 

880 subscription.search_engine = data["search_engine"] 

881 if "search_iterations" in data: 

882 subscription.search_iterations = data["search_iterations"] 

883 if "questions_per_iteration" in data: 

884 subscription.questions_per_iteration = data[ 

885 "questions_per_iteration" 

886 ] 

887 

888 # N14: if this update touched the engine or provider, validate 

889 # the resulting (effective) values against the user's egress 

890 # policy before committing. Best-effort — needs a request 

891 # context to resolve the current user's settings DB. 

892 if "search_engine" in data or "model_provider" in data: 

893 from flask import ( 

894 has_request_context, 

895 session as flask_session, 

896 ) 

897 

898 _uid = ( 

899 flask_session.get("username") 

900 if has_request_context() 

901 else None 

902 ) 

903 if _uid: 

904 policy_reason = _validate_subscription_policy( 

905 db_session, 

906 _uid, 

907 subscription.search_engine, 

908 subscription.model_provider, 

909 ) 

910 if policy_reason is not None: 

911 raise SubscriptionUpdateException( # noqa: TRY301 — re-raised by except NewsAPIException 

912 subscription_id, policy_reason 

913 ) 

914 

915 # Update timestamp 

916 subscription.updated_at = datetime.now(UTC) 

917 

918 # Commit changes 

919 db_session.commit() 

920 

921 # Notify scheduler about updated subscription 

922 _notify_scheduler_about_subscription_change("updated") 

923 

924 # Convert to API format 

925 return { 

926 "status": "success", 

927 "subscription": { 

928 "id": subscription.id, 

929 "name": subscription.name or "", 

930 "query_or_topic": subscription.query_or_topic, 

931 "subscription_type": subscription.subscription_type, 

932 "refresh_interval_minutes": subscription.refresh_interval_minutes, 

933 "is_active": subscription.status == "active", 

934 "status": subscription.status, 

935 "folder_id": subscription.folder_id, 

936 "model_provider": subscription.model_provider, 

937 "model": subscription.model, 

938 "search_strategy": subscription.search_strategy, 

939 "custom_endpoint": subscription.custom_endpoint, 

940 "search_engine": subscription.search_engine, 

941 "search_iterations": subscription.search_iterations or 3, 

942 "questions_per_iteration": subscription.questions_per_iteration 

943 or 5, 

944 }, 

945 } 

946 

947 except NewsAPIException: 

948 # Re-raise our custom exceptions 

949 raise 

950 except Exception: 

951 logger.exception("Error updating subscription") 

952 raise SubscriptionUpdateException( 

953 subscription_id, _GENERIC_ERROR_DETAIL 

954 ) 

955 

956 

957def _validate_subscription_policy( 

958 db_session: Session, user_id, search_engine, model_provider 

959) -> Optional[str]: 

960 """Validate a subscription's search engine + LLM provider against the 

961 user's current egress policy (N14). 

962 

963 News subscriptions store a fixed engine/provider that runs on a 

964 schedule. The factory PEP catches a forbidden engine at execution 

965 time, but validating at create/update time stops a forbidden config 

966 from being persisted in the first place (and gives the user 

967 immediate feedback). Returns a human-readable reason string when the 

968 subscription should be rejected, or None when it's allowed. 

969 

970 Best-effort: if the settings backend or policy module is unavailable 

971 (e.g. programmatic API use without a settings DB), validation is 

972 skipped — the execution-time factory PEP remains the backstop. 

973 """ 

974 try: 

975 from ..utilities.db_utils import get_settings_manager 

976 from ..security.egress.policy import ( 

977 PolicyDeniedError, 

978 context_from_snapshot, 

979 evaluate_engine, 

980 evaluate_llm_endpoint, 

981 ) 

982 

983 settings_manager = get_settings_manager(db_session, user_id) 

984 snapshot = settings_manager.get_settings_snapshot() 

985 if not isinstance(snapshot, dict): 

986 return None 

987 primary = settings_manager.get_setting( 

988 "search.tool", DEFAULT_SEARCH_TOOL 

989 ) 

990 try: 

991 ctx = context_from_snapshot( 

992 snapshot, primary or DEFAULT_SEARCH_TOOL, username=user_id 

993 ) 

994 except PolicyDeniedError as exc: 

995 return f"egress policy refused: {exc.decision.reason}" 

996 except ValueError as exc: 

997 # An incoherent egress config makes context_from_snapshot 

998 # raise ValueError. Surface it as a validation failure at 

999 # subscription create/update time instead of letting the outer 

1000 # except silently skip the check — otherwise the subscription 

1001 # persists and only fails at execution time. Do not echo the raw 

1002 # ValueError to the client (CWE-209): it can carry policy-config 

1003 # internals; log it server-side instead. Mirrors the same fix in 

1004 # web/routes/research_routes.py's start-research precheck. 

1005 logger.bind(policy_audit=True).warning( 

1006 "Subscription egress policy misconfigured", 

1007 reason=str(exc), 

1008 ) 

1009 return "egress policy is misconfigured" 

1010 

1011 if search_engine: 

1012 decision = evaluate_engine( 

1013 search_engine, ctx, settings_snapshot=snapshot 

1014 ) 

1015 if not decision.allowed: 

1016 return ( 

1017 f"search engine '{search_engine}' is not permitted " 

1018 f"under the current egress policy ({decision.reason})" 

1019 ) 

1020 if model_provider: 

1021 decision = evaluate_llm_endpoint( 

1022 normalize_provider(model_provider), 

1023 ctx, 

1024 settings_snapshot=snapshot, 

1025 ) 

1026 if not decision.allowed: 

1027 return ( 

1028 f"LLM provider '{model_provider}' is not permitted " 

1029 f"under the current egress policy ({decision.reason})" 

1030 ) 

1031 return None 

1032 except Exception: 

1033 # Settings/policy unavailable → skip; execution-time PEP backstops. 

1034 logger.debug("subscription policy pre-check skipped", exc_info=True) 

1035 return None 

1036 

1037 

1038def create_subscription( 

1039 user_id: str, 

1040 query: str, 

1041 subscription_type: str = "search", 

1042 refresh_minutes: Optional[int] = None, 

1043 source_research_id: Optional[str] = None, 

1044 model_provider: Optional[str] = None, 

1045 model: Optional[str] = None, 

1046 search_strategy: Optional[str] = None, 

1047 custom_endpoint: Optional[str] = None, 

1048 name: Optional[str] = None, 

1049 folder_id: Optional[str] = None, 

1050 is_active: bool = True, 

1051 search_engine: Optional[str] = None, 

1052 search_iterations: Optional[int] = None, 

1053 questions_per_iteration: Optional[int] = None, 

1054) -> Dict[str, Any]: 

1055 """ 

1056 Create a new subscription for user. 

1057 

1058 Args: 

1059 user_id: User identifier 

1060 query: Search query or topic 

1061 subscription_type: "search" or "topic" 

1062 refresh_minutes: Refresh interval in minutes 

1063 

1064 Returns: 

1065 Dictionary with subscription details 

1066 """ 

1067 try: 

1068 from ..database.session_context import get_user_db_session 

1069 from ..database.models.news import NewsSubscription 

1070 from datetime import datetime, timedelta 

1071 import uuid 

1072 

1073 # Get default refresh interval from settings if not provided 

1074 # NOTE: This API function accesses the settings DB for convenience when used 

1075 # within the Flask application context. For programmatic API access outside 

1076 # the web context, callers should provide refresh_minutes explicitly to avoid 

1077 # dependency on the settings database being initialized. 

1078 

1079 with get_user_db_session(user_id) as db_session: 

1080 # N14: reject a subscription whose engine/provider violates 

1081 # the user's current egress policy before persisting it. 

1082 policy_reason = _validate_subscription_policy( 

1083 db_session, user_id, search_engine, model_provider 

1084 ) 

1085 if policy_reason is not None: 

1086 raise SubscriptionCreationException( # noqa: TRY301 — re-raised as-is by except SubscriptionCreationException 

1087 policy_reason, 

1088 {"query": query, "type": subscription_type}, 

1089 ) 

1090 

1091 if refresh_minutes is None: 

1092 try: 

1093 from ..utilities.db_utils import get_settings_manager 

1094 

1095 settings_manager = get_settings_manager(db_session, user_id) 

1096 refresh_minutes = settings_manager.get_setting( 

1097 "news.subscription.refresh_minutes", 240 

1098 ) 

1099 except (ImportError, AttributeError, TypeError): 

1100 # Fallback for when settings DB is not available (e.g., programmatic API usage) 

1101 logger.debug( 

1102 "Settings manager not available, using default refresh_minutes" 

1103 ) 

1104 refresh_minutes = 240 # Default to 4 hours 

1105 # Create new subscription 

1106 subscription = NewsSubscription( 

1107 id=str(uuid.uuid4()), 

1108 name=name, 

1109 query_or_topic=query, 

1110 subscription_type=subscription_type, 

1111 refresh_interval_minutes=refresh_minutes, 

1112 status="active" if is_active else "paused", 

1113 model_provider=normalize_provider(model_provider), 

1114 model=model, 

1115 search_strategy=search_strategy or "news_aggregation", 

1116 custom_endpoint=custom_endpoint, 

1117 folder_id=folder_id, 

1118 search_engine=search_engine, 

1119 search_iterations=search_iterations, 

1120 questions_per_iteration=questions_per_iteration, 

1121 created_at=datetime.now(UTC), 

1122 updated_at=datetime.now(UTC), 

1123 last_refresh=None, 

1124 next_refresh=datetime.now(UTC) 

1125 + timedelta(minutes=refresh_minutes), 

1126 source_id=source_research_id, 

1127 ) 

1128 

1129 # Add to database 

1130 db_session.add(subscription) 

1131 db_session.commit() 

1132 

1133 # Notify scheduler about new subscription 

1134 _notify_scheduler_about_subscription_change("created", user_id) 

1135 

1136 return { 

1137 "status": "success", 

1138 "subscription_id": subscription.id, 

1139 "type": subscription_type, 

1140 "query": query, 

1141 "refresh_minutes": refresh_minutes, 

1142 } 

1143 

1144 except SubscriptionCreationException: 

1145 # Already a structured creation error (e.g. the N14 policy 

1146 # rejection) — propagate as-is instead of re-wrapping. 

1147 raise 

1148 except Exception: 

1149 logger.exception("Error creating subscription") 

1150 raise SubscriptionCreationException( 

1151 _GENERIC_ERROR_DETAIL, 

1152 {"query": query, "type": subscription_type}, 

1153 ) 

1154 

1155 

1156def delete_subscription(subscription_id: str) -> Dict[str, Any]: 

1157 """ 

1158 Delete a subscription. 

1159 

1160 Args: 

1161 subscription_id: ID of subscription to delete 

1162 

1163 Returns: 

1164 Dictionary with status 

1165 """ 

1166 try: 

1167 from ..database.session_context import get_user_db_session 

1168 from ..database.models.news import NewsSubscription 

1169 

1170 with get_user_db_session() as db_session: 

1171 subscription = ( 

1172 db_session.query(NewsSubscription) 

1173 .filter_by(id=subscription_id) 

1174 .first() 

1175 ) 

1176 if subscription: 

1177 db_session.delete(subscription) 

1178 db_session.commit() 

1179 

1180 # Notify scheduler about deleted subscription 

1181 _notify_scheduler_about_subscription_change("deleted") 

1182 

1183 return {"status": "success", "deleted": subscription_id} 

1184 raise SubscriptionNotFoundException(subscription_id) # noqa: TRY301 — re-raised by except NewsAPIException 

1185 except NewsAPIException: 

1186 # Re-raise our custom exceptions 

1187 raise 

1188 except Exception: 

1189 logger.exception("Error deleting subscription") 

1190 raise SubscriptionDeletionException( 

1191 subscription_id, _GENERIC_ERROR_DETAIL 

1192 ) 

1193 

1194 

1195def get_votes_for_cards(card_ids: list, user_id: str) -> Dict[str, Any]: 

1196 """ 

1197 Get vote counts and user's votes for multiple news cards. 

1198 

1199 Args: 

1200 card_ids: List of card IDs to get votes for 

1201 user_id: User identifier (not used - per-user database) 

1202 

1203 Returns: 

1204 Dictionary with vote information for each card 

1205 """ 

1206 from flask import session as flask_session, has_request_context 

1207 from ..database.models.news import UserRating, RatingType 

1208 from ..database.session_context import get_user_db_session 

1209 

1210 # Resolve username before try block 

1211 if not has_request_context(): 1211 ↛ 1218line 1211 didn't jump to line 1218 because the condition on line 1211 was always true

1212 # If called outside of request context (e.g., in tests), use user_id directly 

1213 username = user_id if user_id else None 

1214 if not username: 

1215 raise ValueError("No username provided and no request context") 

1216 else: 

1217 # Get username from session 

1218 username = flask_session.get("username") 

1219 if not username: 

1220 raise ValueError("No username in session") 

1221 

1222 try: 

1223 # Get database session 

1224 with get_user_db_session(username) as db: 

1225 results = {} 

1226 

1227 for card_id in card_ids: 

1228 # Get user's vote for this card 

1229 user_vote = ( 

1230 db.query(UserRating) 

1231 .filter_by( 

1232 card_id=card_id, rating_type=RatingType.RELEVANCE 

1233 ) 

1234 .first() 

1235 ) 

1236 

1237 # Count total votes for this card 

1238 upvotes = ( 

1239 db.query(UserRating) 

1240 .filter_by( 

1241 card_id=card_id, 

1242 rating_type=RatingType.RELEVANCE, 

1243 rating_value="up", 

1244 ) 

1245 .count() 

1246 ) 

1247 

1248 downvotes = ( 

1249 db.query(UserRating) 

1250 .filter_by( 

1251 card_id=card_id, 

1252 rating_type=RatingType.RELEVANCE, 

1253 rating_value="down", 

1254 ) 

1255 .count() 

1256 ) 

1257 

1258 results[card_id] = { 

1259 "upvotes": upvotes, 

1260 "downvotes": downvotes, 

1261 "user_vote": user_vote.rating_value if user_vote else None, 

1262 } 

1263 

1264 return {"success": True, "votes": results} 

1265 

1266 except Exception: 

1267 logger.exception("Error getting votes for cards") 

1268 raise 

1269 

1270 

1271def submit_feedback(card_id: str, user_id: str, vote: str) -> Dict[str, Any]: 

1272 """ 

1273 Submit feedback (vote) for a news card. 

1274 

1275 Args: 

1276 card_id: ID of the news card 

1277 user_id: User identifier (not used - per-user database) 

1278 vote: "up" or "down" 

1279 

1280 Returns: 

1281 Dictionary with updated vote counts 

1282 """ 

1283 from flask import session as flask_session, has_request_context 

1284 from sqlalchemy_utc import utcnow 

1285 from ..database.models.news import UserRating, RatingType 

1286 from ..database.session_context import get_user_db_session 

1287 

1288 # Validate vote value 

1289 if vote not in ["up", "down"]: 

1290 raise ValueError(f"Invalid vote type: {vote}") 

1291 

1292 # Resolve username before try block 

1293 if not has_request_context(): 1293 ↛ 1300line 1293 didn't jump to line 1300 because the condition on line 1293 was always true

1294 # If called outside of request context (e.g., in tests), use user_id directly 

1295 username = user_id if user_id else None 

1296 if not username: 

1297 raise ValueError("No username provided and no request context") 

1298 else: 

1299 # Get username from session 

1300 username = flask_session.get("username") 

1301 if not username: 

1302 raise ValueError("No username in session") 

1303 

1304 try: 

1305 # Get database session 

1306 with get_user_db_session(username) as db: 

1307 # We don't check if the card exists in the database since news items 

1308 # are generated dynamically and may not be stored as NewsCard entries 

1309 

1310 # Check if user already voted on this card 

1311 existing_rating = ( 

1312 db.query(UserRating) 

1313 .filter_by(card_id=card_id, rating_type=RatingType.RELEVANCE) 

1314 .first() 

1315 ) 

1316 

1317 if existing_rating: 

1318 # Update existing vote 

1319 existing_rating.rating_value = vote 

1320 existing_rating.created_at = utcnow() 

1321 else: 

1322 # Create new rating 

1323 new_rating = UserRating( 

1324 card_id=card_id, 

1325 rating_type=RatingType.RELEVANCE, 

1326 rating_value=vote, 

1327 ) 

1328 db.add(new_rating) 

1329 

1330 db.commit() 

1331 

1332 # Count total votes for this card 

1333 upvotes = ( 

1334 db.query(UserRating) 

1335 .filter_by( 

1336 card_id=card_id, 

1337 rating_type=RatingType.RELEVANCE, 

1338 rating_value="up", 

1339 ) 

1340 .count() 

1341 ) 

1342 

1343 downvotes = ( 

1344 db.query(UserRating) 

1345 .filter_by( 

1346 card_id=card_id, 

1347 rating_type=RatingType.RELEVANCE, 

1348 rating_value="down", 

1349 ) 

1350 .count() 

1351 ) 

1352 

1353 logger.info( 

1354 f"Feedback submitted for card {card_id}: {vote} (up: {upvotes}, down: {downvotes})" 

1355 ) 

1356 

1357 return { 

1358 "success": True, 

1359 "card_id": card_id, 

1360 "vote": vote, 

1361 "upvotes": upvotes, 

1362 "downvotes": downvotes, 

1363 } 

1364 

1365 except Exception: 

1366 logger.exception(f"Error submitting feedback for card {card_id}") 

1367 raise 

1368 

1369 

1370def research_news_item(card_id: str, depth: str = "quick") -> Dict[str, Any]: 

1371 """ 

1372 Perform deeper research on a news item. 

1373 

1374 Args: 

1375 card_id: ID of the news card to research 

1376 depth: Research depth - "quick", "detailed", or "report" 

1377 

1378 Returns: 

1379 Dictionary with research results 

1380 """ 

1381 # TODO: Implement with per-user database for cards 

1382 logger.warning( 

1383 "research_news_item not yet implemented with per-user databases" 

1384 ) 

1385 raise NotImplementedException("research_news_item") 

1386 

1387 

1388def save_news_preferences( 

1389 user_id: str, preferences: Dict[str, Any] 

1390) -> Dict[str, Any]: 

1391 """ 

1392 Save user preferences for news. 

1393 

1394 Args: 

1395 user_id: User identifier 

1396 preferences: Dictionary of preferences to save 

1397 

1398 Returns: 

1399 Dictionary with status and message 

1400 """ 

1401 # TODO: Implement with per-user database for preferences 

1402 logger.warning( 

1403 "save_news_preferences not yet implemented with per-user databases" 

1404 ) 

1405 raise NotImplementedException("save_news_preferences") 

1406 

1407 

1408def get_news_categories() -> Dict[str, Any]: 

1409 """ 

1410 Get available news categories with counts. 

1411 

1412 Returns: 

1413 Dictionary with categories and statistics 

1414 """ 

1415 # TODO: Implement with per-user database for categories 

1416 logger.warning( 

1417 "get_news_categories not yet implemented with per-user databases" 

1418 ) 

1419 raise NotImplementedException("get_news_categories")