Coverage for src/local_deep_research/news/subscription_runner.py: 100%

29 statements  

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

1"""Shared helpers for executing news subscriptions. 

2 

3These centralize logic that was previously copy-pasted across the three places 

4that run a subscription: 

5 

6* ``run_subscription_now`` (manual "run now" button) and 

7 ``check_overdue_subscriptions`` (the overdue sweep) -- both start research 

8 in-process via ``flask_api._call_start_research_internal`` and share the 

9 request payload built here. (They previously issued a loopback HTTP POST to 

10 ``/research/api/start``, which always failed CSRF validation.) 

11* ``BackgroundJobScheduler`` -- triggers research programmatically, but shares 

12 the refresh-schedule arithmetic. 

13 

14Keeping the payload shape and the schedule arithmetic in one place stops the 

15three call sites from drifting (which they previously had: differing metadata, 

16a forgotten search-engine field, and a refresh-time update that one path 

17skipped entirely). 

18""" 

19 

20from datetime import datetime, timedelta, timezone 

21from typing import Any, Dict, Optional 

22 

23 

24def build_subscription_request_data( 

25 *, 

26 query_template: str, 

27 current_date: str, 

28 triggered_by: str, 

29 subscription_id: Any, 

30 model_provider: Optional[str] = None, 

31 model: Optional[str] = None, 

32 search_strategy: Optional[str] = None, 

33 search_engine: Optional[str] = None, 

34 custom_endpoint: Optional[str] = None, 

35 title: Optional[str] = None, 

36 mode: str = "quick", 

37) -> Dict[str, Any]: 

38 """Build the ``/research/api/start`` payload for a subscription run. 

39 

40 Replaces the ``YYYY-MM-DD`` placeholder in ``query_template`` with 

41 ``current_date`` (the user's timezone-local date) and assembles the news 

42 metadata block. Shared by the manual run-now route and the overdue sweep so 

43 their payloads cannot diverge. 

44 

45 ``model_provider``/``model`` are passed through as-is, including falsy 

46 values: an unset provider/model is intentional and makes the backend fall 

47 back to the user's ``llm.provider`` / ``llm.model`` settings (see 

48 ``research_routes._extract_research_params``). Hardcoding a provider here 

49 would override the user's configured default for subscriptions created 

50 without an explicit model. 

51 """ 

52 query = query_template.replace("YYYY-MM-DD", current_date) 

53 

54 request_data: Dict[str, Any] = { 

55 "query": query, 

56 "mode": mode, 

57 "model_provider": model_provider, 

58 "model": model, 

59 "strategy": search_strategy or "news_aggregation", 

60 "metadata": { 

61 "is_news_search": True, 

62 "search_type": "news_analysis", 

63 "display_in": "news_feed", 

64 "subscription_id": str(subscription_id), 

65 "triggered_by": triggered_by, 

66 # Original query keeps the placeholder; processed_query/news_date 

67 # record what was actually run. 

68 "original_query": query_template, 

69 "processed_query": query, 

70 "news_date": current_date, 

71 "title": title or None, 

72 }, 

73 } 

74 

75 # Optional fields are only included when set, matching the research API's 

76 # "absent means use default" contract. 

77 if search_engine: 

78 request_data["search_engine"] = search_engine 

79 if custom_endpoint: 

80 request_data["custom_endpoint"] = custom_endpoint 

81 

82 return request_data 

83 

84 

85def advance_refresh_schedule( 

86 subscription, now: Optional[datetime] = None 

87) -> None: 

88 """Advance a subscription's refresh timestamps after a successful run. 

89 

90 Sets ``last_refresh`` to ``now`` and ``next_refresh`` one 

91 ``refresh_interval_minutes`` later. Shared by the manual run, the overdue 

92 sweep, and the scheduler so the arithmetic -- and remembering to advance 

93 the schedule at all -- lives in one place. 

94 """ 

95 if now is None: 

96 now = datetime.now(timezone.utc) 

97 subscription.last_refresh = now 

98 subscription.next_refresh = now + timedelta( 

99 minutes=subscription.refresh_interval_minutes 

100 ) 

101 

102 

103def advance_refresh_schedule_by_id(db_session, subscription_id) -> bool: 

104 """Load a subscription by id and advance its refresh schedule. 

105 

106 Convenience wrapper for callers that only have an id and an open session 

107 (e.g. the research service's post-completion update, which runs once for 

108 the quick path and once for the detailed path). Returns True if the 

109 subscription was found and advanced. Does not commit -- the caller owns 

110 the transaction. 

111 """ 

112 from ..database.models.news import NewsSubscription 

113 

114 subscription = ( 

115 db_session.query(NewsSubscription) 

116 .filter(NewsSubscription.id == str(subscription_id)) 

117 .first() 

118 ) 

119 if not subscription: 

120 return False 

121 advance_refresh_schedule(subscription, datetime.now(timezone.utc)) 

122 return True 

123 

124 

125def mark_subscription_due_by_id(db_session, subscription_id) -> bool: 

126 """Reset a subscription to "due" after a FAILED run. 

127 

128 ``run_subscription_now`` (and the overdue sweep) advance ``next_refresh`` 

129 at spawn time so the scheduler will not double-run a subscription whose 

130 research is still in flight. If that research then FAILS, the success-only 

131 completion advance never fires, leaving ``next_refresh`` pushed a full 

132 interval into the future and the subscription silently skipped until then. 

133 The research failure handler calls this to reset ``next_refresh`` to now so 

134 the scheduler picks the subscription up again on its next cycle -- matching 

135 the pre-consolidation behavior where a failed manual run left the 

136 subscription due. ``last_refresh`` is intentionally left untouched (it 

137 records the last *successful* refresh). Returns True if the subscription 

138 was found. Does not commit -- the caller owns the transaction. 

139 """ 

140 from ..database.models.news import NewsSubscription 

141 

142 subscription = ( 

143 db_session.query(NewsSubscription) 

144 .filter(NewsSubscription.id == str(subscription_id)) 

145 .first() 

146 ) 

147 if not subscription: 

148 return False 

149 subscription.next_refresh = datetime.now(timezone.utc) 

150 return True