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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""Shared helpers for executing news subscriptions.
3These centralize logic that was previously copy-pasted across the three places
4that run a subscription:
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.
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"""
20from datetime import datetime, timedelta, timezone
21from typing import Any, Dict, Optional
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.
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.
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)
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 }
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
82 return request_data
85def advance_refresh_schedule(
86 subscription, now: Optional[datetime] = None
87) -> None:
88 """Advance a subscription's refresh timestamps after a successful run.
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.
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
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 )
112def advance_refresh_schedule_by_id(db_session, subscription_id) -> bool:
113 """Load a subscription by id and advance its refresh schedule.
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
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
134def mark_subscription_due_by_id(db_session, subscription_id) -> bool:
135 """Reset a subscription to "due" after a FAILED run.
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
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