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
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-19 23:35 +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 ``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)
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.
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 )
103def advance_refresh_schedule_by_id(db_session, subscription_id) -> bool:
104 """Load a subscription by id and advance its refresh schedule.
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
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
125def mark_subscription_due_by_id(db_session, subscription_id) -> bool:
126 """Reset a subscription to "due" after a FAILED run.
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
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