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

424 statements  

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

7from datetime import datetime, timezone, UTC 

8from loguru import logger 

9from sqlalchemy.orm import Session 

10import json 

11 

12from ..constants import ResearchStatus 

13from .constants import validate_refresh_interval 

14from ..llm.providers.base import normalize_provider 

15from ..utilities.sql_utils import escape_like 

16from ..security.log_sanitizer import redact_and_bound_for_log 

17from ..settings.exceptions import SettingsValueError 

18from .exceptions import ( 

19 InvalidLimitException, 

20 SubscriptionNotFoundException, 

21 SubscriptionCreationException, 

22 SubscriptionUpdateException, 

23 SubscriptionDeletionException, 

24 DatabaseAccessException, 

25 NewsFeedGenerationException, 

26 NotImplementedException, 

27 NewsAPIException, 

28) 

29from ..utilities.request_context import ( 

30 get_current_session_id, 

31 get_current_username, 

32) 

33# Removed welcome feed import - no placeholders 

34# get_db_setting not available in merged codebase 

35 

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

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

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

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

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

41_GENERIC_ERROR_DETAIL = "an internal error occurred" 

42 

43 

44def _lazy_log_preview(value: Any, max_length: int) -> Callable[[], str]: 

45 """A ``logger.opt(lazy=True)`` argument: the redacted, bounded value. 

46 

47 Redaction runs before the cut (#6938), and it only runs when a sink 

48 takes the record's level, so a per-row DEBUG line costs nothing at the 

49 default INFO level however long the stored value is. 

50 """ 

51 return lambda: redact_and_bound_for_log(str(value), max_length) 

52 

53 

54def _notify_scheduler_about_subscription_change( 

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

56): 

57 """ 

58 Notify the scheduler about subscription changes. 

59 

60 Args: 

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

62 user_id: Optional user_id to use as fallback for username 

63 """ 

64 try: 

65 from ..scheduler.background import get_background_job_scheduler 

66 

67 scheduler = get_background_job_scheduler() 

68 if scheduler.is_running: 

69 # Get username, with optional fallback to user_id 

70 username = get_current_username() 

71 if not username and user_id: 

72 username = user_id 

73 

74 # Get password from session password store 

75 from ..database.session_passwords import session_password_store 

76 

77 session_id = get_current_session_id() 

78 password = None 

79 if session_id and username: 

80 password = session_password_store.get_session_password( 

81 username, session_id 

82 ) 

83 

84 if password and username: 

85 # Update scheduler to reschedule subscriptions 

86 scheduler.update_user_info(username, password) 

87 logger.info( 

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

89 ) 

90 else: 

91 logger.warning( 

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

93 ) 

94 except Exception: 

95 logger.exception( 

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

97 ) 

98 

99 

100def get_news_feed( 

101 user_id: str = "anonymous", 

102 limit: int = 20, 

103 use_cache: bool = True, 

104 focus: Optional[str] = None, 

105 search_strategy: Optional[str] = None, 

106 subscription_id: Optional[str] = None, 

107) -> Dict[str, Any]: 

108 """ 

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

110 

111 Args: 

112 user_id: User identifier 

113 limit: Maximum number of cards to return 

114 use_cache: Whether to use cached news 

115 focus: Optional focus area for news 

116 search_strategy: Override default recommendation strategy 

117 

118 Returns: 

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

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

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

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

123 """ 

124 # Validate limit - allow any positive number 

125 if limit < 1: 

126 raise InvalidLimitException(limit) 

127 

128 try: 

129 logger.info( 

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

131 ) 

132 

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

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

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

136 

137 # Import database functions 

138 from ..database.session_context import get_user_db_session 

139 from ..database.models import ResearchHistory 

140 from ..text_optimization.citation_formatter import ( 

141 strip_appended_sources_sentinel, 

142 ) 

143 

144 news_items: list[dict[str, Any]] = [] 

145 remaining_limit = limit 

146 

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

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

149 try: 

150 # Use the user_id provided to the function 

151 with get_user_db_session(user_id) as db_session: 

152 # Build query using ORM 

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

154 ResearchHistory.status == ResearchStatus.COMPLETED 

155 ) 

156 

157 # Filter by subscription if provided 

158 if subscription_id and subscription_id != "all": 

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

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

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

162 # include that space too — otherwise the filter silently 

163 # matches zero rows. Mirrors the patterns used in 

164 # get_subscriptions and get_subscription_history below. 

165 query = query.filter( 

166 ResearchHistory.research_meta.like( 

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

168 escape="\\", 

169 ) 

170 ) 

171 

172 # Order by creation date and limit 

173 results = ( 

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

175 .limit(remaining_limit * 2) 

176 .all() 

177 ) 

178 

179 # Convert ORM objects to dictionaries for compatibility 

180 results = [ 

181 { 

182 "id": r.id, 

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

184 "query": r.query, 

185 "title": r.title 

186 if hasattr(r, "title") 

187 else None, # Include title field if exists 

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

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

190 "created_at": r.created_at, 

191 "completed_at": r.completed_at 

192 if r.completed_at 

193 else None, 

194 "duration_seconds": r.duration_seconds 

195 if hasattr(r, "duration_seconds") 

196 else None, 

197 "report_path": r.report_path 

198 if hasattr(r, "report_path") 

199 else None, 

200 "report_content": r.report_content 

201 if hasattr(r, "report_content") 

202 else None, # Include database content 

203 "research_meta": r.research_meta, 

204 "status": r.status, 

205 } 

206 for r in results 

207 ] 

208 

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

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

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

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

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

214 from ..web.services.report_assembly_service import ( 

215 get_research_source_links_batch, 

216 ) 

217 

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

219 links_by_research_id = get_research_source_links_batch( 

220 research_ids_for_links, db_session, limit=3 

221 ) 

222 

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

224 if results and len(results) > 0: 

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

226 # Log first few items' metadata 

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

228 # (#6938: the query is redacted before it is cut. A 

229 # stored query can be tens of KiB and redaction costs 

230 # about 1 us per character, so these per-row lines are 

231 # DEBUG and lazy: nothing is computed unless a sink 

232 # takes DEBUG records.) 

233 logger.opt(lazy=True).debug( 

234 "Item {}: query='{}', has meta={}", 

235 lambda i=i: i, 

236 _lazy_log_preview(row["query"], 50), 

237 lambda row=row: bool(row.get("research_meta")), 

238 ) 

239 

240 # Process results to find news items 

241 processed_count = 0 

242 error_count = 0 

243 

244 for row in results: 

245 try: 

246 # Parse metadata 

247 metadata = {} 

248 if row.get("research_meta"): 

249 try: 

250 # Handle both dict and string formats 

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

252 metadata = row["research_meta"] 

253 else: 

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

255 except (json.JSONDecodeError, TypeError): 

256 logger.exception("Error parsing metadata") 

257 metadata = {} 

258 

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

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

261 has_news_metadata = ( 

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

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

264 ) 

265 

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

267 is_news_query = ( 

268 has_news_metadata 

269 or metadata.get("is_news_search") 

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

271 or "breaking news" in query_lower 

272 or "news stories" in query_lower 

273 or ( 

274 "today" in query_lower 

275 and ( 

276 "news" in query_lower 

277 or "breaking" in query_lower 

278 ) 

279 ) 

280 or "latest news" in query_lower 

281 ) 

282 

283 # Log the decision for first few items 

284 # (#6938: DEBUG and lazy, see the first-rows log above; 

285 # error_count is usually 0, so this runs on every row.) 

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

287 logger.opt(lazy=True).debug( 

288 "Item check: query='{}', is_news_search={}, " 

289 "has_news_metadata={}, is_news_query={}", 

290 _lazy_log_preview(row["query"], 30), 

291 lambda metadata=metadata: metadata.get( 

292 "is_news_search" 

293 ), 

294 lambda v=has_news_metadata: v, 

295 lambda v=is_news_query: v, 

296 ) 

297 

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

299 if is_news_query: 

300 processed_count += 1 

301 logger.opt(lazy=True).debug( 

302 "Processing research item #{}: {}", 

303 lambda n=processed_count: n, 

304 _lazy_log_preview(row["query"], 50), 

305 ) 

306 

307 # Always use database content 

308 findings = "" 

309 summary = "" 

310 report_content_db = row.get( 

311 "report_content" 

312 ) # Get database content 

313 

314 # Use database content. A detailed report saved 

315 # in NO_HYPERLINKS mode, or after the citation 

316 # formatter raised, before the save site removed 

317 # it still carries the report generator's 

318 # appended-sources sentinel, an HTML comment the 

319 # card's renderMarkdown() would show as text. 

320 content = strip_appended_sources_sentinel( 

321 report_content_db 

322 ) 

323 if content: 

324 logger.debug( 

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

326 ) 

327 

328 # Process database content 

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

330 # `findings` is the answer-only report_content 

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

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

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

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

335 # extraction is cleaner without Sources headers in 

336 # the substrate. 

337 findings = content 

338 # Extract summary from first non-empty line 

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

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

341 summary = line.strip() 

342 break 

343 else: 

344 logger.debug( 

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

346 ) 

347 

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

349 original_query = row["query"] 

350 

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

352 # What the "Added news item" line logs: the headline, 

353 # or the whole query when the headline is cut from 

354 # it (#6938: redacted before it is cut). 

355 headline_from_query = False 

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

357 "generated_headline" 

358 ) 

359 

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

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

362 # Use subscription name or query as headline 

363 subscription_name = metadata.get( 

364 "subscription_name" 

365 ) 

366 if subscription_name: 

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

368 else: 

369 # Generate headline from query 

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

371 headline_from_query = True 

372 

373 # Skip items without meaningful headlines or that are incomplete 

374 if ( 

375 not headline 

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

377 ): 

378 logger.debug( 

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

380 ) 

381 continue 

382 

383 # Skip items that are still in progress or suspended 

384 if row["status"] in ( 

385 ResearchStatus.IN_PROGRESS, 

386 ResearchStatus.SUSPENDED, 

387 ): 

388 logger.debug( 

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

390 ) 

391 continue 

392 

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

394 if not content: 

395 logger.debug( 

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

397 ) 

398 continue 

399 

400 # Use ID properly, preferring uuid_id 

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

402 

403 # Use stored category and topics - no defaults 

404 category = metadata.get("category") 

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

406 category = "[Uncategorized]" 

407 

408 topics = metadata.get("generated_topics") 

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

410 topics = ["[No topics]"] 

411 

412 # Top-N links pulled from research_resources via 

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

414 # text parsing of report_content). 

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

416 

417 # Create news item from research 

418 news_item = { 

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

420 "headline": headline, 

421 "category": category, 

422 "summary": summary 

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

424 "findings": findings, 

425 "impact_score": metadata.get( 

426 "impact_score", 0 

427 ), # 0 indicates missing 

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

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

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

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

432 "topics": topics, # Use generated topics 

433 "links": links, # Add extracted links 

434 "research_id": research_id, 

435 "created_at": row["created_at"], 

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

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

438 "is_news": metadata.get( 

439 "is_news_search", False 

440 ), # Flag for news searches 

441 "news_date": metadata.get( 

442 "news_date" 

443 ), # If specific date for news 

444 "news_source": metadata.get( 

445 "news_source" 

446 ), # If from specific source 

447 "priority": metadata.get( 

448 "priority", "normal" 

449 ), # Priority level 

450 } 

451 

452 news_items.append(news_item) 

453 # (#6938: DEBUG and lazy, see the first-rows log.) 

454 logger.opt(lazy=True).debug( 

455 "Added news item: {}", 

456 _lazy_log_preview( 

457 "News: " + str(row["query"]) 

458 if headline_from_query 

459 else headline, 

460 50, 

461 ), 

462 ) 

463 

464 if len(news_items) >= limit: 

465 break 

466 

467 except Exception: 

468 error_count += 1 

469 logger.exception( 

470 "Error processing research item with query: {}", 

471 redact_and_bound_for_log( 

472 str(row.get("query", "UNKNOWN")), 100 

473 ), 

474 ) 

475 continue 

476 

477 logger.info( 

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

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

480 ) 

481 

482 # Log subscription-specific items if we were filtering 

483 if subscription_id and subscription_id != "all": 

484 sub_items = [ 

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

486 ] 

487 logger.info( 

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

489 ) 

490 

491 except Exception as db_error: 

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

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

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

495 # plain ValueError from a formatting helper is not reported 

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

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

498 logger.exception( 

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

500 type(db_error).__name__, 

501 db_error, 

502 ) 

503 raise DatabaseAccessException( 

504 "research_history_query", _GENERIC_ERROR_DETAIL 

505 ) from db_error 

506 

507 # If no news items found, return empty list 

508 if not news_items: 

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

510 news_items = [] 

511 

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

513 

514 # Determine the source 

515 source = ( 

516 "news_items" 

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

518 else "research_history" 

519 ) 

520 

521 return { 

522 "news_items": news_items[:limit], 

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

524 "focus": focus, 

525 "search_strategy": search_strategy or "default", 

526 "total_items": len(news_items), 

527 "source": source, 

528 } 

529 

530 except NewsAPIException: 

531 # Re-raise our custom exceptions 

532 raise 

533 except Exception: 

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

535 raise NewsFeedGenerationException( 

536 _GENERIC_ERROR_DETAIL, user_id=user_id 

537 ) 

538 

539 

540def get_subscription_history( 

541 subscription_id: str, 

542 limit: int = 20, 

543 username: Optional[str] = None, 

544) -> Dict[str, Any]: 

545 """ 

546 Get research history for a specific subscription. 

547 

548 Args: 

549 subscription_id: The subscription UUID 

550 limit: Maximum number of history items to return 

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

552 correct per-user encrypted database) 

553 

554 Returns: 

555 Dict containing subscription info and its research history 

556 """ 

557 try: 

558 from ..database.session_context import get_user_db_session 

559 from ..database.models import ResearchHistory 

560 from ..database.models.news import NewsSubscription 

561 

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

563 with get_user_db_session(username) as session: 

564 subscription = ( 

565 session.query(NewsSubscription) 

566 .filter_by(id=subscription_id) 

567 .first() 

568 ) 

569 

570 if not subscription: 

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

572 

573 # Convert to dict for response 

574 subscription_dict = { 

575 "id": subscription.id, 

576 "query_or_topic": subscription.query_or_topic, 

577 "subscription_type": subscription.subscription_type, 

578 "refresh_interval_minutes": subscription.refresh_interval_minutes, 

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

580 # below (NewsSubscription has no refresh_count column — reading 

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

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

583 if subscription.created_at 

584 else None, 

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

586 if subscription.next_refresh 

587 else None, 

588 } 

589 

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

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

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

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

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

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

596 # session to fall back on). 

597 with get_user_db_session(username) as db_session: 

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

599 # Look for subscription_id in the research_meta JSON 

600 # Note: JSON format has space after colon 

601 like_pattern = ( 

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

603 ) 

604 logger.info( 

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

606 ) 

607 

608 history_items = ( 

609 db_session.query(ResearchHistory) 

610 .filter( 

611 ResearchHistory.research_meta.like( 

612 like_pattern, escape="\\" 

613 ) 

614 ) 

615 .order_by(ResearchHistory.created_at.desc()) 

616 .limit(limit) 

617 .all() 

618 ) 

619 

620 # Convert to dict format for compatibility. 

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

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

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

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

625 history_items = [ 

626 { 

627 "id": h.id, 

628 "uuid_id": h.id, 

629 "query": h.query, 

630 "status": h.status, 

631 # created_at/completed_at are Text columns already holding 

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

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

634 # AttributeError and previously broke this endpoint for any 

635 # subscription that had research history. 

636 "created_at": h.created_at, 

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

638 "duration_seconds": h.duration_seconds, 

639 "research_meta": h.research_meta, 

640 "report_path": h.report_path, 

641 } 

642 for h in history_items 

643 ] 

644 

645 # Process history items 

646 processed_history = [] 

647 for item in history_items: 

648 processed_item = { 

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

650 "query": item["query"], 

651 "status": item["status"], 

652 "created_at": item["created_at"], 

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

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

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

656 } 

657 

658 # Parse metadata if available to get headline and topics 

659 if item.get("research_meta"): 

660 try: 

661 # research_meta is a JSON column, so SQLAlchemy already 

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

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

664 # raised TypeError that the bare except below swallowed, 

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

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

667 # this module (see get_news_feed). 

668 raw_meta = item["research_meta"] 

669 meta = ( 

670 json.loads(raw_meta) 

671 if isinstance(raw_meta, str) 

672 else raw_meta 

673 ) 

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

675 "triggered_by", "subscription" 

676 ) 

677 # Add headline and topics from metadata 

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

679 "generated_headline", "[No headline]" 

680 ) 

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

682 except Exception: 

683 processed_item["headline"] = "[No headline]" 

684 processed_item["topics"] = [] 

685 else: 

686 processed_item["headline"] = "[No headline]" 

687 processed_item["topics"] = [] 

688 

689 processed_history.append(processed_item) 

690 

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

692 # the subscription row carries no counter. 

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

694 

695 return { 

696 "subscription": subscription_dict, 

697 "history": processed_history, 

698 "total_runs": len(processed_history), 

699 } 

700 

701 except NewsAPIException: 

702 # Re-raise our custom exceptions 

703 raise 

704 except Exception: 

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

706 raise DatabaseAccessException( 

707 "get_subscription_history", _GENERIC_ERROR_DETAIL 

708 ) 

709 

710 

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

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

713 

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

715 ResearchHistory.created_at is a NOT NULL column written as 

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

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

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

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

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

721 a misleading "Recently". 

722 """ 

723 from dateutil import parser 

724 

725 dt = parser.parse(timestamp) 

726 

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

728 if dt.tzinfo is None: 

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

730 

731 now = datetime.now(timezone.utc) 

732 diff = now - dt 

733 

734 if diff.days > 0: 

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

736 if diff.seconds > 3600: 

737 hours = diff.seconds // 3600 

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

739 if diff.seconds > 60: 

740 minutes = diff.seconds // 60 

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

742 return "Just now" 

743 

744 

745def get_subscription( 

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

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

748 """ 

749 Get a single subscription by ID. 

750 

751 Args: 

752 subscription_id: Subscription identifier 

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

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

755 to Flask session and will fail under FastAPI. 

756 

757 Returns: 

758 Dictionary with subscription data or None if not found 

759 """ 

760 try: 

761 # Get subscription directly from user's encrypted database 

762 from ..database.session_context import get_user_db_session 

763 from ..database.models.news import NewsSubscription 

764 

765 with get_user_db_session(username) as db_session: 

766 subscription = ( 

767 db_session.query(NewsSubscription) 

768 .filter_by(id=subscription_id) 

769 .first() 

770 ) 

771 

772 if not subscription: 

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

774 

775 # Convert to API format matching the template expectations 

776 return { 

777 "id": subscription.id, 

778 "name": subscription.name or "", 

779 "query_or_topic": subscription.query_or_topic, 

780 "subscription_type": subscription.subscription_type, 

781 "refresh_interval_minutes": subscription.refresh_interval_minutes, 

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

783 "status": subscription.status, 

784 "folder_id": subscription.folder_id, 

785 "model_provider": subscription.model_provider, 

786 "model": subscription.model, 

787 "search_strategy": subscription.search_strategy, 

788 "custom_endpoint": subscription.custom_endpoint, 

789 "search_engine": subscription.search_engine, 

790 "search_iterations": subscription.search_iterations or 3, 

791 "questions_per_iteration": subscription.questions_per_iteration 

792 or 5, 

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

794 if subscription.created_at 

795 else None, 

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

797 if subscription.updated_at 

798 else None, 

799 } 

800 

801 except NewsAPIException: 

802 # Re-raise our custom exceptions 

803 raise 

804 except Exception: 

805 from ..web.routers.notes import _log_value_preview 

806 

807 logger.exception( 

808 "Error getting subscription {}", _log_value_preview(subscription_id) 

809 ) 

810 raise DatabaseAccessException("get_subscription", _GENERIC_ERROR_DETAIL) 

811 

812 

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

814 """ 

815 Get all subscriptions for a user. 

816 

817 Args: 

818 user_id: User identifier 

819 

820 Returns: 

821 Dictionary with subscriptions list 

822 """ 

823 try: 

824 # Get subscriptions directly from user's encrypted database 

825 from ..database.session_context import get_user_db_session 

826 from ..database.models import ResearchHistory 

827 from ..database.models.news import NewsSubscription 

828 from sqlalchemy import func 

829 

830 sub_list: list[dict[str, Any]] = [] 

831 

832 with get_user_db_session(user_id) as db_session: 

833 # Query all subscriptions for this user 

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

835 

836 for sub in subscriptions: 

837 # Count actual research runs for this subscription. 

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

839 # too so every subscription_id LIKE in this module is 

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

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

842 total_runs = ( 

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

844 .filter( 

845 ResearchHistory.research_meta.like( 

846 like_pattern, escape="\\" 

847 ) 

848 ) 

849 .scalar() 

850 or 0 

851 ) 

852 

853 # Convert ORM object to API format 

854 sub_dict = { 

855 "id": sub.id, 

856 "query": sub.query_or_topic, 

857 "type": sub.subscription_type, 

858 "refresh_minutes": sub.refresh_interval_minutes, 

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

860 if sub.created_at 

861 else None, 

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

863 if sub.next_refresh 

864 else None, 

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

866 if sub.last_refresh 

867 else None, 

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

869 "status": sub.status, 

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

871 "name": sub.name or "", 

872 "folder_id": sub.folder_id, 

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

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

875 # saved configuration instead of silently getting None and 

876 # falling back to global defaults. 

877 "model_provider": sub.model_provider, 

878 "model": sub.model, 

879 "search_strategy": sub.search_strategy, 

880 "search_engine": sub.search_engine, 

881 "custom_endpoint": sub.custom_endpoint, 

882 "search_iterations": sub.search_iterations, 

883 "questions_per_iteration": sub.questions_per_iteration, 

884 } 

885 sub_list.append(sub_dict) 

886 

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

888 

889 except Exception: 

890 logger.exception("Error getting subscriptions") 

891 raise DatabaseAccessException( 

892 "get_subscriptions", _GENERIC_ERROR_DETAIL 

893 ) 

894 

895 

896def update_subscription( 

897 subscription_id: str, 

898 data: Dict[str, Any], 

899 username: Optional[str] = None, 

900) -> Dict[str, Any]: 

901 """ 

902 Update an existing subscription. 

903 

904 Args: 

905 subscription_id: Subscription identifier 

906 data: Dictionary with fields to update 

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

908 user's encrypted database. 

909 

910 Returns: 

911 Dictionary with updated subscription data 

912 """ 

913 if "refresh_interval_minutes" in data: 

914 validate_refresh_interval(data["refresh_interval_minutes"]) 

915 

916 try: 

917 from ..database.session_context import get_user_db_session 

918 from ..database.models.news import NewsSubscription 

919 from datetime import datetime, timedelta 

920 

921 with get_user_db_session(username) as db_session: 

922 # Get existing subscription 

923 subscription = ( 

924 db_session.query(NewsSubscription) 

925 .filter_by(id=subscription_id) 

926 .first() 

927 ) 

928 if not subscription: 

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

930 

931 # Update fields 

932 if "name" in data: 

933 subscription.name = data["name"] 

934 if "query_or_topic" in data: 

935 subscription.query_or_topic = data["query_or_topic"] 

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

937 subscription.subscription_type = data["subscription_type"] 

938 if "refresh_interval_minutes" in data: 

939 old_interval = subscription.refresh_interval_minutes 

940 subscription.refresh_interval_minutes = data[ 

941 "refresh_interval_minutes" 

942 ] 

943 # Recalculate next_refresh if interval changed 

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

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

946 minutes=subscription.refresh_interval_minutes 

947 ) 

948 if "is_active" in data: 

949 subscription.status = ( 

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

951 ) 

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

953 subscription.status = data["status"] 

954 if "folder_id" in data: 

955 subscription.folder_id = data["folder_id"] 

956 if "model_provider" in data: 

957 subscription.model_provider = normalize_provider( 

958 data["model_provider"] 

959 ) 

960 if "model" in data: 

961 subscription.model = data["model"] 

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

963 subscription.search_strategy = data["search_strategy"] 

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

965 subscription.custom_endpoint = data["custom_endpoint"] 

966 if "search_engine" in data: 

967 subscription.search_engine = data["search_engine"] 

968 if "search_iterations" in data: 

969 subscription.search_iterations = data["search_iterations"] 

970 if "questions_per_iteration" in data: 

971 subscription.questions_per_iteration = data[ 

972 "questions_per_iteration" 

973 ] 

974 

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

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

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

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

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

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

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

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

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

984 # egress-policy check. 

985 _uid = username or get_current_username() 

986 if _uid: 

987 policy_reason = _validate_subscription_policy( 

988 db_session, 

989 _uid, 

990 subscription.search_engine, 

991 subscription.model_provider, 

992 ) 

993 if policy_reason is not None: 

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

995 subscription_id, policy_reason 

996 ) 

997 

998 # Update timestamp 

999 subscription.updated_at = datetime.now(UTC) 

1000 

1001 # Commit changes 

1002 db_session.commit() 

1003 

1004 # Notify scheduler about updated subscription 

1005 _notify_scheduler_about_subscription_change("updated") 

1006 

1007 # Convert to API format 

1008 return { 

1009 "status": "success", 

1010 "subscription": { 

1011 "id": subscription.id, 

1012 "name": subscription.name or "", 

1013 "query_or_topic": subscription.query_or_topic, 

1014 "subscription_type": subscription.subscription_type, 

1015 "refresh_interval_minutes": subscription.refresh_interval_minutes, 

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

1017 "status": subscription.status, 

1018 "folder_id": subscription.folder_id, 

1019 "model_provider": subscription.model_provider, 

1020 "model": subscription.model, 

1021 "search_strategy": subscription.search_strategy, 

1022 "custom_endpoint": subscription.custom_endpoint, 

1023 "search_engine": subscription.search_engine, 

1024 "search_iterations": subscription.search_iterations or 3, 

1025 "questions_per_iteration": subscription.questions_per_iteration 

1026 or 5, 

1027 }, 

1028 } 

1029 

1030 except NewsAPIException: 

1031 # Re-raise our custom exceptions 

1032 raise 

1033 except Exception: 

1034 logger.exception("Error updating subscription") 

1035 raise SubscriptionUpdateException( 

1036 subscription_id, _GENERIC_ERROR_DETAIL 

1037 ) 

1038 

1039 

1040def _validate_subscription_policy( 

1041 db_session: Session, user_id, search_engine, model_provider 

1042) -> Optional[str]: 

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

1044 user's current egress policy (N14). 

1045 

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

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

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

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

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

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

1052 

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

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

1055 skipped — the execution-time factory PEP remains the backstop. An 

1056 invalid configured setting is not such a failure: the check cannot run, 

1057 so the subscription is rejected with the setting's diagnostic, which 

1058 names the key but not the value. 

1059 """ 

1060 try: 

1061 from ..utilities.db_utils import get_settings_manager 

1062 from ..security.egress.policy import ( 

1063 PolicyDeniedError, 

1064 context_from_snapshot, 

1065 evaluate_engine, 

1066 evaluate_llm_endpoint, 

1067 resolve_run_primary_engine, 

1068 ) 

1069 

1070 settings_manager = get_settings_manager(db_session, user_id) 

1071 snapshot = settings_manager.get_settings_snapshot() 

1072 if not isinstance(snapshot, dict): 

1073 return None 

1074 try: 

1075 ctx = context_from_snapshot( 

1076 snapshot, 

1077 resolve_run_primary_engine(snapshot), 

1078 username=user_id, 

1079 ) 

1080 except PolicyDeniedError as exc: 

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

1082 except SettingsValueError: 

1083 # A ValueError subclass, but not an incoherent policy: the outer 

1084 # handler names the invalid setting. 

1085 raise 

1086 except ValueError as exc: 

1087 # An incoherent egress config makes context_from_snapshot 

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

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

1090 # except silently skip the check — otherwise the subscription 

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

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

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

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

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

1096 "Subscription egress policy misconfigured", 

1097 reason=str(exc), 

1098 ) 

1099 return "egress policy is misconfigured" 

1100 

1101 if search_engine: 

1102 decision = evaluate_engine( 

1103 search_engine, ctx, settings_snapshot=snapshot 

1104 ) 

1105 if not decision.allowed: 

1106 return ( 

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

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

1109 ) 

1110 if model_provider: 

1111 decision = evaluate_llm_endpoint( 

1112 normalize_provider(model_provider), 

1113 ctx, 

1114 settings_snapshot=snapshot, 

1115 ) 

1116 if not decision.allowed: 

1117 return ( 

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

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

1120 ) 

1121 return None 

1122 except SettingsValueError as error: 

1123 # The snapshot read validates every effective setting, so one invalid 

1124 # row anywhere lands here. Skipping the check would persist a 

1125 # subscription the policy was never applied to. 

1126 return error.diagnostic 

1127 except Exception: 

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

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

1130 return None 

1131 

1132 

1133def create_subscription( 

1134 user_id: str, 

1135 query: str, 

1136 subscription_type: str = "search", 

1137 refresh_minutes: Optional[int] = None, 

1138 source_research_id: Optional[str] = None, 

1139 model_provider: Optional[str] = None, 

1140 model: Optional[str] = None, 

1141 search_strategy: Optional[str] = None, 

1142 custom_endpoint: Optional[str] = None, 

1143 name: Optional[str] = None, 

1144 folder_id: Optional[str] = None, 

1145 is_active: bool = True, 

1146 search_engine: Optional[str] = None, 

1147 search_iterations: Optional[int] = None, 

1148 questions_per_iteration: Optional[int] = None, 

1149) -> Dict[str, Any]: 

1150 """ 

1151 Create a new subscription for user. 

1152 

1153 Args: 

1154 user_id: User identifier 

1155 query: Search query or topic 

1156 subscription_type: "search" or "topic" 

1157 refresh_minutes: Refresh interval in minutes 

1158 

1159 Returns: 

1160 Dictionary with subscription details 

1161 """ 

1162 try: 

1163 from ..database.session_context import get_user_db_session 

1164 from ..database.models.news import NewsSubscription 

1165 from datetime import datetime, timedelta 

1166 import uuid 

1167 

1168 # Get default refresh interval from settings if not provided 

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

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

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

1172 # dependency on the settings database being initialized. 

1173 

1174 with get_user_db_session(user_id) as db_session: 

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

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

1177 policy_reason = _validate_subscription_policy( 

1178 db_session, user_id, search_engine, model_provider 

1179 ) 

1180 if policy_reason is not None: 

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

1182 policy_reason, 

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

1184 ) 

1185 

1186 if refresh_minutes is None: 

1187 try: 

1188 from ..utilities.db_utils import get_settings_manager 

1189 

1190 settings_manager = get_settings_manager(db_session, user_id) 

1191 refresh_minutes = settings_manager.get_setting( 

1192 "news.subscription.refresh_minutes", 240 

1193 ) 

1194 except (ImportError, AttributeError, TypeError): 

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

1196 logger.debug( 

1197 "Settings manager not available, using default refresh_minutes" 

1198 ) 

1199 refresh_minutes = 240 # Default to 4 hours 

1200 validate_refresh_interval(refresh_minutes) 

1201 

1202 # Create new subscription 

1203 subscription = NewsSubscription( 

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

1205 name=name, 

1206 query_or_topic=query, 

1207 subscription_type=subscription_type, 

1208 refresh_interval_minutes=refresh_minutes, 

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

1210 model_provider=normalize_provider(model_provider), 

1211 model=model, 

1212 search_strategy=search_strategy or "news_aggregation", 

1213 custom_endpoint=custom_endpoint, 

1214 folder_id=folder_id, 

1215 search_engine=search_engine, 

1216 search_iterations=search_iterations, 

1217 questions_per_iteration=questions_per_iteration, 

1218 created_at=datetime.now(UTC), 

1219 updated_at=datetime.now(UTC), 

1220 last_refresh=None, 

1221 next_refresh=datetime.now(UTC) 

1222 + timedelta(minutes=refresh_minutes), 

1223 source_id=source_research_id, 

1224 ) 

1225 

1226 # Add to database 

1227 db_session.add(subscription) 

1228 db_session.commit() 

1229 

1230 # Notify scheduler about new subscription 

1231 _notify_scheduler_about_subscription_change("created", user_id) 

1232 

1233 return { 

1234 "status": "success", 

1235 "subscription_id": subscription.id, 

1236 "type": subscription_type, 

1237 "query": query, 

1238 "refresh_minutes": refresh_minutes, 

1239 } 

1240 

1241 except NewsAPIException: 

1242 # Preserve structured policy and input-validation errors. 

1243 raise 

1244 except Exception: 

1245 logger.exception("Error creating subscription") 

1246 raise SubscriptionCreationException( 

1247 _GENERIC_ERROR_DETAIL, 

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

1249 ) 

1250 

1251 

1252def delete_subscription( 

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

1254) -> Dict[str, Any]: 

1255 """ 

1256 Delete a subscription. 

1257 

1258 Args: 

1259 subscription_id: ID of subscription to delete 

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

1261 

1262 Returns: 

1263 Dictionary with status 

1264 """ 

1265 try: 

1266 from ..database.session_context import get_user_db_session 

1267 from ..database.models.news import NewsSubscription 

1268 

1269 with get_user_db_session(username) as db_session: 

1270 subscription = ( 

1271 db_session.query(NewsSubscription) 

1272 .filter_by(id=subscription_id) 

1273 .first() 

1274 ) 

1275 if subscription: 

1276 db_session.delete(subscription) 

1277 db_session.commit() 

1278 

1279 # Notify scheduler about deleted subscription 

1280 _notify_scheduler_about_subscription_change("deleted") 

1281 

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

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

1284 except NewsAPIException: 

1285 # Re-raise our custom exceptions 

1286 raise 

1287 except Exception: 

1288 logger.exception("Error deleting subscription") 

1289 raise SubscriptionDeletionException( 

1290 subscription_id, _GENERIC_ERROR_DETAIL 

1291 ) 

1292 

1293 

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

1295 """ 

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

1297 

1298 Args: 

1299 card_ids: List of card IDs to get votes for 

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

1301 

1302 Returns: 

1303 Dictionary with vote information for each card 

1304 """ 

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

1306 from ..database.session_context import get_user_db_session 

1307 

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

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

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

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

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

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

1314 # drops Flask from dependencies. 

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 results = {} 

1323 

1324 for card_id in card_ids: 

1325 # Get user's vote for this card 

1326 user_vote = ( 

1327 db.query(UserRating) 

1328 .filter_by( 

1329 card_id=card_id, rating_type=RatingType.RELEVANCE 

1330 ) 

1331 .first() 

1332 ) 

1333 

1334 # Count total votes for this card 

1335 upvotes = ( 

1336 db.query(UserRating) 

1337 .filter_by( 

1338 card_id=card_id, 

1339 rating_type=RatingType.RELEVANCE, 

1340 rating_value="up", 

1341 ) 

1342 .count() 

1343 ) 

1344 

1345 downvotes = ( 

1346 db.query(UserRating) 

1347 .filter_by( 

1348 card_id=card_id, 

1349 rating_type=RatingType.RELEVANCE, 

1350 rating_value="down", 

1351 ) 

1352 .count() 

1353 ) 

1354 

1355 results[card_id] = { 

1356 "upvotes": upvotes, 

1357 "downvotes": downvotes, 

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

1359 } 

1360 

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

1362 

1363 except Exception: 

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

1365 raise 

1366 

1367 

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

1369 """ 

1370 Submit feedback (vote) for a news card. 

1371 

1372 Args: 

1373 card_id: ID of the news card 

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

1375 vote: "up" or "down" 

1376 

1377 Returns: 

1378 Dictionary with updated vote counts 

1379 """ 

1380 from sqlalchemy_utc import utcnow 

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

1382 from ..database.session_context import get_user_db_session 

1383 

1384 # Validate vote value 

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

1386 # (#6938: the vote is raw request input and this message reaches 

1387 # the router's logged traceback, so it carries the bounded preview.) 

1388 from ..web.routers.notes import _log_value_preview 

1389 

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

1391 

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

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

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

1395 # `has_request_context()` gate is gone. 

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

1397 if not username: 

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

1399 

1400 try: 

1401 # Get database session 

1402 with get_user_db_session(username) as db: 

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

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

1405 

1406 # Check if user already voted on this card 

1407 existing_rating = ( 

1408 db.query(UserRating) 

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

1410 .first() 

1411 ) 

1412 

1413 if existing_rating: 

1414 # Update existing vote 

1415 existing_rating.rating_value = vote 

1416 existing_rating.created_at = utcnow() 

1417 else: 

1418 # Create new rating 

1419 new_rating = UserRating( 

1420 card_id=card_id, 

1421 rating_type=RatingType.RELEVANCE, 

1422 rating_value=vote, 

1423 ) 

1424 db.add(new_rating) 

1425 

1426 db.commit() 

1427 

1428 # Count total votes for this card 

1429 upvotes = ( 

1430 db.query(UserRating) 

1431 .filter_by( 

1432 card_id=card_id, 

1433 rating_type=RatingType.RELEVANCE, 

1434 rating_value="up", 

1435 ) 

1436 .count() 

1437 ) 

1438 

1439 downvotes = ( 

1440 db.query(UserRating) 

1441 .filter_by( 

1442 card_id=card_id, 

1443 rating_type=RatingType.RELEVANCE, 

1444 rating_value="down", 

1445 ) 

1446 .count() 

1447 ) 

1448 

1449 # (#6938: card_id is the raw body/path value.) 

1450 from ..web.routers.notes import _log_value_preview 

1451 

1452 logger.info( 

1453 "Feedback submitted for card {}: {} (up: {}, down: {})", 

1454 _log_value_preview(card_id), 

1455 vote, 

1456 upvotes, 

1457 downvotes, 

1458 ) 

1459 

1460 return { 

1461 "success": True, 

1462 "card_id": card_id, 

1463 "vote": vote, 

1464 "upvotes": upvotes, 

1465 "downvotes": downvotes, 

1466 } 

1467 

1468 except Exception: 

1469 from ..web.routers.notes import _log_value_preview 

1470 

1471 logger.exception( 

1472 "Error submitting feedback for card {}", 

1473 _log_value_preview(card_id), 

1474 ) 

1475 raise 

1476 

1477 

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

1479 """ 

1480 Perform deeper research on a news item. 

1481 

1482 Args: 

1483 card_id: ID of the news card to research 

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

1485 

1486 Returns: 

1487 Dictionary with research results 

1488 """ 

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

1490 logger.warning( 

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

1492 ) 

1493 raise NotImplementedException("research_news_item") 

1494 

1495 

1496def save_news_preferences( 

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

1498) -> Dict[str, Any]: 

1499 """ 

1500 Save user preferences for news. 

1501 

1502 Args: 

1503 user_id: User identifier 

1504 preferences: Dictionary of preferences to save 

1505 

1506 Returns: 

1507 Dictionary with status and message 

1508 """ 

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

1510 logger.warning( 

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

1512 ) 

1513 raise NotImplementedException("save_news_preferences") 

1514 

1515 

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

1517 """ 

1518 Get available news categories with counts. 

1519 

1520 Returns: 

1521 Dictionary with categories and statistics 

1522 """ 

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

1524 logger.warning( 

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

1526 ) 

1527 raise NotImplementedException("get_news_categories")