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

407 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +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 ..utilities.request_context import ( 

27 get_current_session_id, 

28 get_current_username, 

29) 

30from ..constants import DEFAULT_SEARCH_TOOL 

31# Removed welcome feed import - no placeholders 

32# get_db_setting not available in merged codebase 

33 

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

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

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

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

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

39_GENERIC_ERROR_DETAIL = "an internal error occurred" 

40 

41 

42def _notify_scheduler_about_subscription_change( 

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

44): 

45 """ 

46 Notify the scheduler about subscription changes. 

47 

48 Args: 

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

50 user_id: Optional user_id to use as fallback for username 

51 """ 

52 try: 

53 from ..scheduler.background import get_background_job_scheduler 

54 

55 scheduler = get_background_job_scheduler() 

56 if scheduler.is_running: 

57 # Get username, with optional fallback to user_id 

58 username = get_current_username() 

59 if not username and user_id: 

60 username = user_id 

61 

62 # Get password from session password store 

63 from ..database.session_passwords import session_password_store 

64 

65 session_id = get_current_session_id() 

66 password = None 

67 if session_id and username: 

68 password = session_password_store.get_session_password( 

69 username, session_id 

70 ) 

71 

72 if password and username: 

73 # Update scheduler to reschedule subscriptions 

74 scheduler.update_user_info(username, password) 

75 logger.info( 

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

77 ) 

78 else: 

79 logger.warning( 

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

81 ) 

82 except Exception: 

83 logger.exception( 

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

85 ) 

86 

87 

88def get_news_feed( 

89 user_id: str = "anonymous", 

90 limit: int = 20, 

91 use_cache: bool = True, 

92 focus: Optional[str] = None, 

93 search_strategy: Optional[str] = None, 

94 subscription_id: Optional[str] = None, 

95) -> Dict[str, Any]: 

96 """ 

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

98 

99 Args: 

100 user_id: User identifier 

101 limit: Maximum number of cards to return 

102 use_cache: Whether to use cached news 

103 focus: Optional focus area for news 

104 search_strategy: Override default recommendation strategy 

105 

106 Returns: 

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

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

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

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

111 """ 

112 # Validate limit - allow any positive number 

113 if limit < 1: 

114 raise InvalidLimitException(limit) 

115 

116 try: 

117 logger.info( 

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

119 ) 

120 

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

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

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

124 

125 # Import database functions 

126 from ..database.session_context import get_user_db_session 

127 from ..database.models import ResearchHistory 

128 

129 news_items = [] 

130 remaining_limit = limit 

131 

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

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

134 try: 

135 # Use the user_id provided to the function 

136 with get_user_db_session(user_id) as db_session: 

137 # Build query using ORM 

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

139 ResearchHistory.status == ResearchStatus.COMPLETED 

140 ) 

141 

142 # Filter by subscription if provided 

143 if subscription_id and subscription_id != "all": 

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

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

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

147 # include that space too — otherwise the filter silently 

148 # matches zero rows. Mirrors the patterns used in 

149 # get_subscriptions and get_subscription_history below. 

150 query = query.filter( 

151 ResearchHistory.research_meta.like( 

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

153 escape="\\", 

154 ) 

155 ) 

156 

157 # Order by creation date and limit 

158 results = ( 

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

160 .limit(remaining_limit * 2) 

161 .all() 

162 ) 

163 

164 # Convert ORM objects to dictionaries for compatibility 

165 results = [ 

166 { 

167 "id": r.id, 

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

169 "query": r.query, 

170 "title": r.title 

171 if hasattr(r, "title") 

172 else None, # Include title field if exists 

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

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

175 "created_at": r.created_at, 

176 "completed_at": r.completed_at 

177 if r.completed_at 

178 else None, 

179 "duration_seconds": r.duration_seconds 

180 if hasattr(r, "duration_seconds") 

181 else None, 

182 "report_path": r.report_path 

183 if hasattr(r, "report_path") 

184 else None, 

185 "report_content": r.report_content 

186 if hasattr(r, "report_content") 

187 else None, # Include database content 

188 "research_meta": r.research_meta, 

189 "status": r.status, 

190 } 

191 for r in results 

192 ] 

193 

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

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

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

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

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

199 from ..web.services.report_assembly_service import ( 

200 get_research_source_links_batch, 

201 ) 

202 

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

204 links_by_research_id = get_research_source_links_batch( 

205 research_ids_for_links, db_session, limit=3 

206 ) 

207 

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

209 if results and len(results) > 0: 

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

211 # Log first few items' metadata 

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

213 logger.info( 

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

215 ) 

216 

217 # Process results to find news items 

218 processed_count = 0 

219 error_count = 0 

220 

221 for row in results: 

222 try: 

223 # Parse metadata 

224 metadata = {} 

225 if row.get("research_meta"): 

226 try: 

227 # Handle both dict and string formats 

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

229 metadata = row["research_meta"] 

230 else: 

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

232 except (json.JSONDecodeError, TypeError): 

233 logger.exception("Error parsing metadata") 

234 metadata = {} 

235 

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

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

238 has_news_metadata = ( 

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

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

241 ) 

242 

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

244 is_news_query = ( 

245 has_news_metadata 

246 or metadata.get("is_news_search") 

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

248 or "breaking news" in query_lower 

249 or "news stories" in query_lower 

250 or ( 

251 "today" in query_lower 

252 and ( 

253 "news" in query_lower 

254 or "breaking" in query_lower 

255 ) 

256 ) 

257 or "latest news" in query_lower 

258 ) 

259 

260 # Log the decision for first few items 

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

262 logger.info( 

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

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

265 ) 

266 

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

268 if is_news_query: 

269 processed_count += 1 

270 logger.info( 

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

272 ) 

273 

274 # Always use database content 

275 findings = "" 

276 summary = "" 

277 report_content_db = row.get( 

278 "report_content" 

279 ) # Get database content 

280 

281 # Use database content 

282 content = report_content_db 

283 if content: 

284 logger.debug( 

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

286 ) 

287 

288 # Process database content 

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

290 # `findings` is the answer-only report_content 

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

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

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

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

295 # extraction is cleaner without Sources headers in 

296 # the substrate. 

297 findings = content 

298 # Extract summary from first non-empty line 

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

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

301 summary = line.strip() 

302 break 

303 else: 

304 logger.debug( 

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

306 ) 

307 

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

309 original_query = row["query"] 

310 

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

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

313 "generated_headline" 

314 ) 

315 

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

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

318 # Use subscription name or query as headline 

319 subscription_name = metadata.get( 

320 "subscription_name" 

321 ) 

322 if subscription_name: 

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

324 else: 

325 # Generate headline from query 

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

327 

328 # Skip items without meaningful headlines or that are incomplete 

329 if ( 

330 not headline 

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

332 ): 

333 logger.debug( 

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

335 ) 

336 continue 

337 

338 # Skip items that are still in progress or suspended 

339 if row["status"] in ( 

340 ResearchStatus.IN_PROGRESS, 

341 ResearchStatus.SUSPENDED, 

342 ): 

343 logger.debug( 

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

345 ) 

346 continue 

347 

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

349 if not content: 

350 logger.debug( 

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

352 ) 

353 continue 

354 

355 # Use ID properly, preferring uuid_id 

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

357 

358 # Use stored category and topics - no defaults 

359 category = metadata.get("category") 

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

361 category = "[Uncategorized]" 

362 

363 topics = metadata.get("generated_topics") 

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

365 topics = ["[No topics]"] 

366 

367 # Top-N links pulled from research_resources via 

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

369 # text parsing of report_content). 

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

371 

372 # Create news item from research 

373 news_item = { 

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

375 "headline": headline, 

376 "category": category, 

377 "summary": summary 

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

379 "findings": findings, 

380 "impact_score": metadata.get( 

381 "impact_score", 0 

382 ), # 0 indicates missing 

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

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

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

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

387 "topics": topics, # Use generated topics 

388 "links": links, # Add extracted links 

389 "research_id": research_id, 

390 "created_at": row["created_at"], 

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

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

393 "is_news": metadata.get( 

394 "is_news_search", False 

395 ), # Flag for news searches 

396 "news_date": metadata.get( 

397 "news_date" 

398 ), # If specific date for news 

399 "news_source": metadata.get( 

400 "news_source" 

401 ), # If from specific source 

402 "priority": metadata.get( 

403 "priority", "normal" 

404 ), # Priority level 

405 } 

406 

407 news_items.append(news_item) 

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

409 

410 if len(news_items) >= limit: 

411 break 

412 

413 except Exception: 

414 error_count += 1 

415 logger.exception( 

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

417 ) 

418 continue 

419 

420 logger.info( 

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

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

423 ) 

424 

425 # Log subscription-specific items if we were filtering 

426 if subscription_id and subscription_id != "all": 

427 sub_items = [ 

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

429 ] 

430 logger.info( 

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

432 ) 

433 

434 except Exception as db_error: 

435 # This block is wide enough to catch far more than database 

436 # errors -- everything the feed does with a row runs inside 

437 # it. Name the concrete exception type and chain it, so a 

438 # plain ValueError from a formatting helper is not reported 

439 # to the logs as "Database error" with no way back to the 

440 # real raise site. The client still gets the generic detail. 

441 logger.exception( 

442 "Error in research history query ({}): {}", 

443 type(db_error).__name__, 

444 db_error, 

445 ) 

446 raise DatabaseAccessException( 

447 "research_history_query", _GENERIC_ERROR_DETAIL 

448 ) from db_error 

449 

450 # If no news items found, return empty list 

451 if not news_items: 

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

453 news_items = [] 

454 

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

456 

457 # Determine the source 

458 source = ( 

459 "news_items" 

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

461 else "research_history" 

462 ) 

463 

464 return { 

465 "news_items": news_items[:limit], 

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

467 "focus": focus, 

468 "search_strategy": search_strategy or "default", 

469 "total_items": len(news_items), 

470 "source": source, 

471 } 

472 

473 except NewsAPIException: 

474 # Re-raise our custom exceptions 

475 raise 

476 except Exception: 

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

478 raise NewsFeedGenerationException( 

479 _GENERIC_ERROR_DETAIL, user_id=user_id 

480 ) 

481 

482 

483def get_subscription_history( 

484 subscription_id: str, 

485 limit: int = 20, 

486 username: Optional[str] = None, 

487) -> Dict[str, Any]: 

488 """ 

489 Get research history for a specific subscription. 

490 

491 Args: 

492 subscription_id: The subscription UUID 

493 limit: Maximum number of history items to return 

494 username: The owner of the subscription (required so we open the 

495 correct per-user encrypted database) 

496 

497 Returns: 

498 Dict containing subscription info and its research history 

499 """ 

500 try: 

501 from ..database.session_context import get_user_db_session 

502 from ..database.models import ResearchHistory 

503 from ..database.models.news import NewsSubscription 

504 

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

506 with get_user_db_session(username) as session: 

507 subscription = ( 

508 session.query(NewsSubscription) 

509 .filter_by(id=subscription_id) 

510 .first() 

511 ) 

512 

513 if not subscription: 

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

515 

516 # Convert to dict for response 

517 subscription_dict = { 

518 "id": subscription.id, 

519 "query_or_topic": subscription.query_or_topic, 

520 "subscription_type": subscription.subscription_type, 

521 "refresh_interval_minutes": subscription.refresh_interval_minutes, 

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

523 # below (NewsSubscription has no refresh_count column — reading 

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

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

526 if subscription.created_at 

527 else None, 

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

529 if subscription.next_refresh 

530 else None, 

531 } 

532 

533 # Use the same per-user DB for research history (subscriptions and 

534 # ResearchHistory live in the same encrypted database). The 

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

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

537 # whichever user's DB we found the subscription in, identified by the 

538 # explicit ``username`` arg (the FastAPI service layer has no Flask 

539 # session to fall back on). 

540 with get_user_db_session(username) as db_session: 

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

542 # Look for subscription_id in the research_meta JSON 

543 # Note: JSON format has space after colon 

544 like_pattern = ( 

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

546 ) 

547 logger.info( 

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

549 ) 

550 

551 history_items = ( 

552 db_session.query(ResearchHistory) 

553 .filter( 

554 ResearchHistory.research_meta.like( 

555 like_pattern, escape="\\" 

556 ) 

557 ) 

558 .order_by(ResearchHistory.created_at.desc()) 

559 .limit(limit) 

560 .all() 

561 ) 

562 

563 # Convert to dict format for compatibility. 

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

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

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

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

568 history_items = [ 

569 { 

570 "id": h.id, 

571 "uuid_id": h.id, 

572 "query": h.query, 

573 "status": h.status, 

574 # created_at/completed_at are Text columns already holding 

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

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

577 # AttributeError and previously broke this endpoint for any 

578 # subscription that had research history. 

579 "created_at": h.created_at, 

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

581 "duration_seconds": h.duration_seconds, 

582 "research_meta": h.research_meta, 

583 "report_path": h.report_path, 

584 } 

585 for h in history_items 

586 ] 

587 

588 # Process history items 

589 processed_history = [] 

590 for item in history_items: 

591 processed_item = { 

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

593 "query": item["query"], 

594 "status": item["status"], 

595 "created_at": item["created_at"], 

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

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

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

599 } 

600 

601 # Parse metadata if available to get headline and topics 

602 if item.get("research_meta"): 

603 try: 

604 # research_meta is a JSON column, so SQLAlchemy already 

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

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

607 # raised TypeError that the bare except below swallowed, 

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

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

610 # this module (see get_news_feed). 

611 raw_meta = item["research_meta"] 

612 meta = ( 

613 json.loads(raw_meta) 

614 if isinstance(raw_meta, str) 

615 else raw_meta 

616 ) 

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

618 "triggered_by", "subscription" 

619 ) 

620 # Add headline and topics from metadata 

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

622 "generated_headline", "[No headline]" 

623 ) 

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

625 except Exception: 

626 processed_item["headline"] = "[No headline]" 

627 processed_item["topics"] = [] 

628 else: 

629 processed_item["headline"] = "[No headline]" 

630 processed_item["topics"] = [] 

631 

632 processed_history.append(processed_item) 

633 

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

635 # the subscription row carries no counter. 

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

637 

638 return { 

639 "subscription": subscription_dict, 

640 "history": processed_history, 

641 "total_runs": len(processed_history), 

642 } 

643 

644 except NewsAPIException: 

645 # Re-raise our custom exceptions 

646 raise 

647 except Exception: 

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

649 raise DatabaseAccessException( 

650 "get_subscription_history", _GENERIC_ERROR_DETAIL 

651 ) 

652 

653 

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

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

656 

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

658 ResearchHistory.created_at is a NOT NULL column written as 

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

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

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

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

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

664 a misleading "Recently". 

665 """ 

666 from dateutil import parser 

667 

668 dt = parser.parse(timestamp) 

669 

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

671 if dt.tzinfo is None: 

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

673 

674 now = datetime.now(timezone.utc) 

675 diff = now - dt 

676 

677 if diff.days > 0: 

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

679 if diff.seconds > 3600: 

680 hours = diff.seconds // 3600 

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

682 if diff.seconds > 60: 

683 minutes = diff.seconds // 60 

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

685 return "Just now" 

686 

687 

688def get_subscription( 

689 subscription_id: str, username: Optional[str] = None 

690) -> Optional[Dict[str, Any]]: 

691 """ 

692 Get a single subscription by ID. 

693 

694 Args: 

695 subscription_id: Subscription identifier 

696 username: User identifier; required to scope the lookup to that 

697 user's encrypted database. Without it the lookup falls back 

698 to Flask session and will fail under FastAPI. 

699 

700 Returns: 

701 Dictionary with subscription data or None if not found 

702 """ 

703 try: 

704 # Get subscription directly from user's encrypted database 

705 from ..database.session_context import get_user_db_session 

706 from ..database.models.news import NewsSubscription 

707 

708 with get_user_db_session(username) as db_session: 

709 subscription = ( 

710 db_session.query(NewsSubscription) 

711 .filter_by(id=subscription_id) 

712 .first() 

713 ) 

714 

715 if not subscription: 

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

717 

718 # Convert to API format matching the template expectations 

719 return { 

720 "id": subscription.id, 

721 "name": subscription.name or "", 

722 "query_or_topic": subscription.query_or_topic, 

723 "subscription_type": subscription.subscription_type, 

724 "refresh_interval_minutes": subscription.refresh_interval_minutes, 

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

726 "status": subscription.status, 

727 "folder_id": subscription.folder_id, 

728 "model_provider": subscription.model_provider, 

729 "model": subscription.model, 

730 "search_strategy": subscription.search_strategy, 

731 "custom_endpoint": subscription.custom_endpoint, 

732 "search_engine": subscription.search_engine, 

733 "search_iterations": subscription.search_iterations or 3, 

734 "questions_per_iteration": subscription.questions_per_iteration 

735 or 5, 

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

737 if subscription.created_at 

738 else None, 

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

740 if subscription.updated_at 

741 else None, 

742 } 

743 

744 except NewsAPIException: 

745 # Re-raise our custom exceptions 

746 raise 

747 except Exception: 

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

749 raise DatabaseAccessException("get_subscription", _GENERIC_ERROR_DETAIL) 

750 

751 

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

753 """ 

754 Get all subscriptions for a user. 

755 

756 Args: 

757 user_id: User identifier 

758 

759 Returns: 

760 Dictionary with subscriptions list 

761 """ 

762 try: 

763 # Get subscriptions directly from user's encrypted database 

764 from ..database.session_context import get_user_db_session 

765 from ..database.models import ResearchHistory 

766 from ..database.models.news import NewsSubscription 

767 from sqlalchemy import func 

768 

769 sub_list = [] 

770 

771 with get_user_db_session(user_id) as db_session: 

772 # Query all subscriptions for this user 

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

774 

775 for sub in subscriptions: 

776 # Count actual research runs for this subscription. 

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

778 # too so every subscription_id LIKE in this module is 

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

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

781 total_runs = ( 

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

783 .filter( 

784 ResearchHistory.research_meta.like( 

785 like_pattern, escape="\\" 

786 ) 

787 ) 

788 .scalar() 

789 or 0 

790 ) 

791 

792 # Convert ORM object to API format 

793 sub_dict = { 

794 "id": sub.id, 

795 "query": sub.query_or_topic, 

796 "type": sub.subscription_type, 

797 "refresh_minutes": sub.refresh_interval_minutes, 

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

799 if sub.created_at 

800 else None, 

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

802 if sub.next_refresh 

803 else None, 

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

805 if sub.last_refresh 

806 else None, 

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

808 "status": sub.status, 

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

810 "name": sub.name or "", 

811 "folder_id": sub.folder_id, 

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

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

814 # saved configuration instead of silently getting None and 

815 # falling back to global defaults. 

816 "model_provider": sub.model_provider, 

817 "model": sub.model, 

818 "search_strategy": sub.search_strategy, 

819 "search_engine": sub.search_engine, 

820 "custom_endpoint": sub.custom_endpoint, 

821 "search_iterations": sub.search_iterations, 

822 "questions_per_iteration": sub.questions_per_iteration, 

823 } 

824 sub_list.append(sub_dict) 

825 

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

827 

828 except Exception: 

829 logger.exception("Error getting subscriptions") 

830 raise DatabaseAccessException( 

831 "get_subscriptions", _GENERIC_ERROR_DETAIL 

832 ) 

833 

834 

835def update_subscription( 

836 subscription_id: str, 

837 data: Dict[str, Any], 

838 username: Optional[str] = None, 

839) -> Dict[str, Any]: 

840 """ 

841 Update an existing subscription. 

842 

843 Args: 

844 subscription_id: Subscription identifier 

845 data: Dictionary with fields to update 

846 username: User identifier; required to scope the update to that 

847 user's encrypted database. 

848 

849 Returns: 

850 Dictionary with updated subscription data 

851 """ 

852 try: 

853 from ..database.session_context import get_user_db_session 

854 from ..database.models.news import NewsSubscription 

855 from datetime import datetime, timedelta 

856 

857 with get_user_db_session(username) as db_session: 

858 # Get existing subscription 

859 subscription = ( 

860 db_session.query(NewsSubscription) 

861 .filter_by(id=subscription_id) 

862 .first() 

863 ) 

864 if not subscription: 

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

866 

867 # Update fields 

868 if "name" in data: 

869 subscription.name = data["name"] 

870 if "query_or_topic" in data: 

871 subscription.query_or_topic = data["query_or_topic"] 

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

873 subscription.subscription_type = data["subscription_type"] 

874 if "refresh_interval_minutes" in data: 

875 old_interval = subscription.refresh_interval_minutes 

876 subscription.refresh_interval_minutes = data[ 

877 "refresh_interval_minutes" 

878 ] 

879 # Recalculate next_refresh if interval changed 

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

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

882 minutes=subscription.refresh_interval_minutes 

883 ) 

884 if "is_active" in data: 

885 subscription.status = ( 

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

887 ) 

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

889 subscription.status = data["status"] 

890 if "folder_id" in data: 

891 subscription.folder_id = data["folder_id"] 

892 if "model_provider" in data: 

893 subscription.model_provider = normalize_provider( 

894 data["model_provider"] 

895 ) 

896 if "model" in data: 

897 subscription.model = data["model"] 

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

899 subscription.search_strategy = data["search_strategy"] 

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

901 subscription.custom_endpoint = data["custom_endpoint"] 

902 if "search_engine" in data: 

903 subscription.search_engine = data["search_engine"] 

904 if "search_iterations" in data: 

905 subscription.search_iterations = data["search_iterations"] 

906 if "questions_per_iteration" in data: 

907 subscription.questions_per_iteration = data[ 

908 "questions_per_iteration" 

909 ] 

910 

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

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

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

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

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

916 # FastAPI: resolve the user from the explicit param (the router 

917 # passes username=username), falling back to the request-context 

918 # var. The old Flask `has_request_context()` gate was always 

919 # False under FastAPI, silently skipping this create/update-time 

920 # egress-policy check. 

921 _uid = username or get_current_username() 

922 if _uid: 

923 policy_reason = _validate_subscription_policy( 

924 db_session, 

925 _uid, 

926 subscription.search_engine, 

927 subscription.model_provider, 

928 ) 

929 if policy_reason is not None: 

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

931 subscription_id, policy_reason 

932 ) 

933 

934 # Update timestamp 

935 subscription.updated_at = datetime.now(UTC) 

936 

937 # Commit changes 

938 db_session.commit() 

939 

940 # Notify scheduler about updated subscription 

941 _notify_scheduler_about_subscription_change("updated") 

942 

943 # Convert to API format 

944 return { 

945 "status": "success", 

946 "subscription": { 

947 "id": subscription.id, 

948 "name": subscription.name or "", 

949 "query_or_topic": subscription.query_or_topic, 

950 "subscription_type": subscription.subscription_type, 

951 "refresh_interval_minutes": subscription.refresh_interval_minutes, 

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

953 "status": subscription.status, 

954 "folder_id": subscription.folder_id, 

955 "model_provider": subscription.model_provider, 

956 "model": subscription.model, 

957 "search_strategy": subscription.search_strategy, 

958 "custom_endpoint": subscription.custom_endpoint, 

959 "search_engine": subscription.search_engine, 

960 "search_iterations": subscription.search_iterations or 3, 

961 "questions_per_iteration": subscription.questions_per_iteration 

962 or 5, 

963 }, 

964 } 

965 

966 except NewsAPIException: 

967 # Re-raise our custom exceptions 

968 raise 

969 except Exception: 

970 logger.exception("Error updating subscription") 

971 raise SubscriptionUpdateException( 

972 subscription_id, _GENERIC_ERROR_DETAIL 

973 ) 

974 

975 

976def _validate_subscription_policy( 

977 db_session: Session, user_id, search_engine, model_provider 

978) -> Optional[str]: 

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

980 user's current egress policy (N14). 

981 

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

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

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

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

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

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

988 

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

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

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

992 """ 

993 try: 

994 from ..utilities.db_utils import get_settings_manager 

995 from ..security.egress.policy import ( 

996 PolicyDeniedError, 

997 context_from_snapshot, 

998 evaluate_engine, 

999 evaluate_llm_endpoint, 

1000 ) 

1001 

1002 settings_manager = get_settings_manager(db_session, user_id) 

1003 snapshot = settings_manager.get_settings_snapshot() 

1004 if not isinstance(snapshot, dict): 

1005 return None 

1006 primary = settings_manager.get_setting( 

1007 "search.tool", DEFAULT_SEARCH_TOOL 

1008 ) 

1009 try: 

1010 ctx = context_from_snapshot( 

1011 snapshot, primary or DEFAULT_SEARCH_TOOL, username=user_id 

1012 ) 

1013 except PolicyDeniedError as exc: 

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

1015 except ValueError as exc: 

1016 # An incoherent egress config makes context_from_snapshot 

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

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

1019 # except silently skip the check — otherwise the subscription 

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

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

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

1023 # web/routers/research.py's start-research precheck. 

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

1025 "Subscription egress policy misconfigured", 

1026 reason=str(exc), 

1027 ) 

1028 return "egress policy is misconfigured" 

1029 

1030 if search_engine: 

1031 decision = evaluate_engine( 

1032 search_engine, ctx, settings_snapshot=snapshot 

1033 ) 

1034 if not decision.allowed: 

1035 return ( 

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

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

1038 ) 

1039 if model_provider: 

1040 decision = evaluate_llm_endpoint( 

1041 normalize_provider(model_provider), 

1042 ctx, 

1043 settings_snapshot=snapshot, 

1044 ) 

1045 if not decision.allowed: 

1046 return ( 

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

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

1049 ) 

1050 return None 

1051 except Exception: 

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

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

1054 return None 

1055 

1056 

1057def create_subscription( 

1058 user_id: str, 

1059 query: str, 

1060 subscription_type: str = "search", 

1061 refresh_minutes: Optional[int] = None, 

1062 source_research_id: Optional[str] = None, 

1063 model_provider: Optional[str] = None, 

1064 model: Optional[str] = None, 

1065 search_strategy: Optional[str] = None, 

1066 custom_endpoint: Optional[str] = None, 

1067 name: Optional[str] = None, 

1068 folder_id: Optional[str] = None, 

1069 is_active: bool = True, 

1070 search_engine: Optional[str] = None, 

1071 search_iterations: Optional[int] = None, 

1072 questions_per_iteration: Optional[int] = None, 

1073) -> Dict[str, Any]: 

1074 """ 

1075 Create a new subscription for user. 

1076 

1077 Args: 

1078 user_id: User identifier 

1079 query: Search query or topic 

1080 subscription_type: "search" or "topic" 

1081 refresh_minutes: Refresh interval in minutes 

1082 

1083 Returns: 

1084 Dictionary with subscription details 

1085 """ 

1086 try: 

1087 from ..database.session_context import get_user_db_session 

1088 from ..database.models.news import NewsSubscription 

1089 from datetime import datetime, timedelta 

1090 import uuid 

1091 

1092 # Get default refresh interval from settings if not provided 

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

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

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

1096 # dependency on the settings database being initialized. 

1097 

1098 with get_user_db_session(user_id) as db_session: 

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

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

1101 policy_reason = _validate_subscription_policy( 

1102 db_session, user_id, search_engine, model_provider 

1103 ) 

1104 if policy_reason is not None: 

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

1106 policy_reason, 

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

1108 ) 

1109 

1110 if refresh_minutes is None: 

1111 try: 

1112 from ..utilities.db_utils import get_settings_manager 

1113 

1114 settings_manager = get_settings_manager(db_session, user_id) 

1115 refresh_minutes = settings_manager.get_setting( 

1116 "news.subscription.refresh_minutes", 240 

1117 ) 

1118 except (ImportError, AttributeError, TypeError): 

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

1120 logger.debug( 

1121 "Settings manager not available, using default refresh_minutes" 

1122 ) 

1123 refresh_minutes = 240 # Default to 4 hours 

1124 # Create new subscription 

1125 subscription = NewsSubscription( 

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

1127 name=name, 

1128 query_or_topic=query, 

1129 subscription_type=subscription_type, 

1130 refresh_interval_minutes=refresh_minutes, 

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

1132 model_provider=normalize_provider(model_provider), 

1133 model=model, 

1134 search_strategy=search_strategy or "news_aggregation", 

1135 custom_endpoint=custom_endpoint, 

1136 folder_id=folder_id, 

1137 search_engine=search_engine, 

1138 search_iterations=search_iterations, 

1139 questions_per_iteration=questions_per_iteration, 

1140 created_at=datetime.now(UTC), 

1141 updated_at=datetime.now(UTC), 

1142 last_refresh=None, 

1143 next_refresh=datetime.now(UTC) 

1144 + timedelta(minutes=refresh_minutes), 

1145 source_id=source_research_id, 

1146 ) 

1147 

1148 # Add to database 

1149 db_session.add(subscription) 

1150 db_session.commit() 

1151 

1152 # Notify scheduler about new subscription 

1153 _notify_scheduler_about_subscription_change("created", user_id) 

1154 

1155 return { 

1156 "status": "success", 

1157 "subscription_id": subscription.id, 

1158 "type": subscription_type, 

1159 "query": query, 

1160 "refresh_minutes": refresh_minutes, 

1161 } 

1162 

1163 except SubscriptionCreationException: 

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

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

1166 raise 

1167 except Exception: 

1168 logger.exception("Error creating subscription") 

1169 raise SubscriptionCreationException( 

1170 _GENERIC_ERROR_DETAIL, 

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

1172 ) 

1173 

1174 

1175def delete_subscription( 

1176 subscription_id: str, username: Optional[str] = None 

1177) -> Dict[str, Any]: 

1178 """ 

1179 Delete a subscription. 

1180 

1181 Args: 

1182 subscription_id: ID of subscription to delete 

1183 username: User identifier; required to scope to user's encrypted DB. 

1184 

1185 Returns: 

1186 Dictionary with status 

1187 """ 

1188 try: 

1189 from ..database.session_context import get_user_db_session 

1190 from ..database.models.news import NewsSubscription 

1191 

1192 with get_user_db_session(username) as db_session: 

1193 subscription = ( 

1194 db_session.query(NewsSubscription) 

1195 .filter_by(id=subscription_id) 

1196 .first() 

1197 ) 

1198 if subscription: 

1199 db_session.delete(subscription) 

1200 db_session.commit() 

1201 

1202 # Notify scheduler about deleted subscription 

1203 _notify_scheduler_about_subscription_change("deleted") 

1204 

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

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

1207 except NewsAPIException: 

1208 # Re-raise our custom exceptions 

1209 raise 

1210 except Exception: 

1211 logger.exception("Error deleting subscription") 

1212 raise SubscriptionDeletionException( 

1213 subscription_id, _GENERIC_ERROR_DETAIL 

1214 ) 

1215 

1216 

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

1218 """ 

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

1220 

1221 Args: 

1222 card_ids: List of card IDs to get votes for 

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

1224 

1225 Returns: 

1226 Dictionary with vote information for each card 

1227 """ 

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

1229 from ..database.session_context import get_user_db_session 

1230 

1231 # Prefer the request's authenticated username (FastAPI contextvar), 

1232 # fall back to the explicit `user_id` arg for off-request callers 

1233 # like tests. The earlier version gated this on Flask's 

1234 # `has_request_context()` which is always False under FastAPI 

1235 # (Flask not running), so it always fell through to `user_id` — 

1236 # and that import will raise ImportError outright once Phase 8 

1237 # drops Flask from dependencies. 

1238 username = get_current_username() or (user_id if user_id else None) 

1239 if not username: 

1240 raise ValueError("No username available from context or user_id") 

1241 

1242 try: 

1243 # Get database session 

1244 with get_user_db_session(username) as db: 

1245 results = {} 

1246 

1247 for card_id in card_ids: 

1248 # Get user's vote for this card 

1249 user_vote = ( 

1250 db.query(UserRating) 

1251 .filter_by( 

1252 card_id=card_id, rating_type=RatingType.RELEVANCE 

1253 ) 

1254 .first() 

1255 ) 

1256 

1257 # Count total votes for this card 

1258 upvotes = ( 

1259 db.query(UserRating) 

1260 .filter_by( 

1261 card_id=card_id, 

1262 rating_type=RatingType.RELEVANCE, 

1263 rating_value="up", 

1264 ) 

1265 .count() 

1266 ) 

1267 

1268 downvotes = ( 

1269 db.query(UserRating) 

1270 .filter_by( 

1271 card_id=card_id, 

1272 rating_type=RatingType.RELEVANCE, 

1273 rating_value="down", 

1274 ) 

1275 .count() 

1276 ) 

1277 

1278 results[card_id] = { 

1279 "upvotes": upvotes, 

1280 "downvotes": downvotes, 

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

1282 } 

1283 

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

1285 

1286 except Exception: 

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

1288 raise 

1289 

1290 

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

1292 """ 

1293 Submit feedback (vote) for a news card. 

1294 

1295 Args: 

1296 card_id: ID of the news card 

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

1298 vote: "up" or "down" 

1299 

1300 Returns: 

1301 Dictionary with updated vote counts 

1302 """ 

1303 from sqlalchemy_utc import utcnow 

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

1305 from ..database.session_context import get_user_db_session 

1306 

1307 # Validate vote value 

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

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

1310 

1311 # Prefer the request's authenticated username (FastAPI contextvar), 

1312 # fall back to the explicit `user_id` arg for off-request callers. 

1313 # See the sibling `get_votes_for_cards` for why the Flask 

1314 # `has_request_context()` gate is gone. 

1315 username = get_current_username() or (user_id if user_id else None) 

1316 if not username: 

1317 raise ValueError("No username available from context or user_id") 

1318 

1319 try: 

1320 # Get database session 

1321 with get_user_db_session(username) as db: 

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

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

1324 

1325 # Check if user already voted on this card 

1326 existing_rating = ( 

1327 db.query(UserRating) 

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

1329 .first() 

1330 ) 

1331 

1332 if existing_rating: 

1333 # Update existing vote 

1334 existing_rating.rating_value = vote 

1335 existing_rating.created_at = utcnow() 

1336 else: 

1337 # Create new rating 

1338 new_rating = UserRating( 

1339 card_id=card_id, 

1340 rating_type=RatingType.RELEVANCE, 

1341 rating_value=vote, 

1342 ) 

1343 db.add(new_rating) 

1344 

1345 db.commit() 

1346 

1347 # Count total votes for this card 

1348 upvotes = ( 

1349 db.query(UserRating) 

1350 .filter_by( 

1351 card_id=card_id, 

1352 rating_type=RatingType.RELEVANCE, 

1353 rating_value="up", 

1354 ) 

1355 .count() 

1356 ) 

1357 

1358 downvotes = ( 

1359 db.query(UserRating) 

1360 .filter_by( 

1361 card_id=card_id, 

1362 rating_type=RatingType.RELEVANCE, 

1363 rating_value="down", 

1364 ) 

1365 .count() 

1366 ) 

1367 

1368 logger.info( 

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

1370 ) 

1371 

1372 return { 

1373 "success": True, 

1374 "card_id": card_id, 

1375 "vote": vote, 

1376 "upvotes": upvotes, 

1377 "downvotes": downvotes, 

1378 } 

1379 

1380 except Exception: 

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

1382 raise 

1383 

1384 

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

1386 """ 

1387 Perform deeper research on a news item. 

1388 

1389 Args: 

1390 card_id: ID of the news card to research 

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

1392 

1393 Returns: 

1394 Dictionary with research results 

1395 """ 

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

1397 logger.warning( 

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

1399 ) 

1400 raise NotImplementedException("research_news_item") 

1401 

1402 

1403def save_news_preferences( 

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

1405) -> Dict[str, Any]: 

1406 """ 

1407 Save user preferences for news. 

1408 

1409 Args: 

1410 user_id: User identifier 

1411 preferences: Dictionary of preferences to save 

1412 

1413 Returns: 

1414 Dictionary with status and message 

1415 """ 

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

1417 logger.warning( 

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

1419 ) 

1420 raise NotImplementedException("save_news_preferences") 

1421 

1422 

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

1424 """ 

1425 Get available news categories with counts. 

1426 

1427 Returns: 

1428 Dictionary with categories and statistics 

1429 """ 

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

1431 logger.warning( 

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

1433 ) 

1434 raise NotImplementedException("get_news_categories")