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

31 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +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 ``web/routers/research.py::_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 Raises ``NewsAPIException`` (``INVALID_REFRESH_INTERVAL``) before 

96 touching either timestamp when the saved interval is not runnable (see 

97 ``is_runnable_refresh_interval``). That is the saved-row bound, not the 

98 narrower cap on newly written values, so a long interval saved by an 

99 older version still advances. 

100 """ 

101 from .constants import validate_runnable_refresh_interval 

102 

103 validate_runnable_refresh_interval(subscription.refresh_interval_minutes) 

104 if now is None: 

105 now = datetime.now(timezone.utc) 

106 subscription.last_refresh = now 

107 subscription.next_refresh = now + timedelta( 

108 minutes=subscription.refresh_interval_minutes 

109 ) 

110 

111 

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

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

114 

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

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

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

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

119 the transaction. 

120 """ 

121 from ..database.models.news import NewsSubscription 

122 

123 subscription = ( 

124 db_session.query(NewsSubscription) 

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

126 .first() 

127 ) 

128 if not subscription: 

129 return False 

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

131 return True 

132 

133 

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

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

136 

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

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

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

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

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

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

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

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

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

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

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

148 """ 

149 from ..database.models.news import NewsSubscription 

150 

151 subscription = ( 

152 db_session.query(NewsSubscription) 

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

154 .first() 

155 ) 

156 if not subscription: 

157 return False 

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

159 return True