open-pharma-plugins 2.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcp_framework.py +495 -0
- open_pharma_plugins-2.2.0.dist-info/METADATA +135 -0
- open_pharma_plugins-2.2.0.dist-info/RECORD +136 -0
- open_pharma_plugins-2.2.0.dist-info/WHEEL +5 -0
- open_pharma_plugins-2.2.0.dist-info/entry_points.txt +8 -0
- open_pharma_plugins-2.2.0.dist-info/licenses/LICENSE +202 -0
- open_pharma_plugins-2.2.0.dist-info/top_level.txt +8 -0
- open_pharma_plugins_campaign_studio/__init__.py +14 -0
- open_pharma_plugins_campaign_studio/__main__.py +11 -0
- open_pharma_plugins_campaign_studio/_campaign_store.py +162 -0
- open_pharma_plugins_campaign_studio/_claim_engine.py +262 -0
- open_pharma_plugins_campaign_studio/_renderer.py +119 -0
- open_pharma_plugins_campaign_studio/fixtures/brand_kit/legal.json +21 -0
- open_pharma_plugins_campaign_studio/fixtures/brand_kit/logo.svg +4 -0
- open_pharma_plugins_campaign_studio/fixtures/brand_kit/palette.json +11 -0
- open_pharma_plugins_campaign_studio/fixtures/brand_kit/product.png +1 -0
- open_pharma_plugins_campaign_studio/fixtures/brand_kit/typography.json +14 -0
- open_pharma_plugins_campaign_studio/fixtures/sample_approved_claims.json +119 -0
- open_pharma_plugins_campaign_studio/models/__init__.py +34 -0
- open_pharma_plugins_campaign_studio/models/_common.py +12 -0
- open_pharma_plugins_campaign_studio/models/brief.py +72 -0
- open_pharma_plugins_campaign_studio/models/claims.py +15 -0
- open_pharma_plugins_campaign_studio/models/copy.py +47 -0
- open_pharma_plugins_campaign_studio/models/journey.py +21 -0
- open_pharma_plugins_campaign_studio/models/message.py +25 -0
- open_pharma_plugins_campaign_studio/models/mlr.py +29 -0
- open_pharma_plugins_campaign_studio/models/validation.py +32 -0
- open_pharma_plugins_campaign_studio/policy/rules.json +79 -0
- open_pharma_plugins_campaign_studio/templates/banner.svg.j2 +26 -0
- open_pharma_plugins_campaign_studio/templates/email.html.j2 +54 -0
- open_pharma_plugins_campaign_studio/tools/__init__.py +0 -0
- open_pharma_plugins_campaign_studio/tools/create_campaign_brief.py +212 -0
- open_pharma_plugins_campaign_studio/tools/generate_audience_journey.py +129 -0
- open_pharma_plugins_campaign_studio/tools/generate_channel_copy.py +199 -0
- open_pharma_plugins_campaign_studio/tools/generate_message_architecture.py +121 -0
- open_pharma_plugins_campaign_studio/tools/package_mlr_submission.py +230 -0
- open_pharma_plugins_campaign_studio/tools/render_banner.py +99 -0
- open_pharma_plugins_campaign_studio/tools/render_email.py +101 -0
- open_pharma_plugins_campaign_studio/tools/render_poster.py +222 -0
- open_pharma_plugins_campaign_studio/tools/retrieve_approved_claims.py +71 -0
- open_pharma_plugins_campaign_studio/tools/retrieve_brand_components.py +76 -0
- open_pharma_plugins_campaign_studio/tools/validate_claims_and_fair_balance.py +285 -0
- open_pharma_plugins_competitive_intelligence/__init__.py +13 -0
- open_pharma_plugins_competitive_intelligence/__main__.py +11 -0
- open_pharma_plugins_competitive_intelligence/_artifacts.py +87 -0
- open_pharma_plugins_competitive_intelligence/_cache.py +144 -0
- open_pharma_plugins_competitive_intelligence/_clinical_trials.py +569 -0
- open_pharma_plugins_competitive_intelligence/_dailymed.py +260 -0
- open_pharma_plugins_competitive_intelligence/_fda.py +255 -0
- open_pharma_plugins_competitive_intelligence/_pubmed.py +342 -0
- open_pharma_plugins_competitive_intelligence/_regulatory.py +140 -0
- open_pharma_plugins_competitive_intelligence/_runs.py +278 -0
- open_pharma_plugins_competitive_intelligence/_transport.py +83 -0
- open_pharma_plugins_competitive_intelligence/_watchlist.py +113 -0
- open_pharma_plugins_competitive_intelligence/_web_search.py +331 -0
- open_pharma_plugins_competitive_intelligence/models.py +525 -0
- open_pharma_plugins_competitive_intelligence/tools/__init__.py +0 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_extract_events.py +247 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_landscape.py +195 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_refresh.py +101 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_report.py +402 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_scan_news.py +48 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_scan_publications.py +46 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_scan_regulatory.py +64 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_scan_trials.py +85 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_status.py +106 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_timeline.py +453 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_track.py +121 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_trial_detail.py +55 -0
- open_pharma_plugins_field_training/__init__.py +13 -0
- open_pharma_plugins_field_training/__main__.py +11 -0
- open_pharma_plugins_field_training/_content_store.py +131 -0
- open_pharma_plugins_field_training/_grounding.py +75 -0
- open_pharma_plugins_field_training/_html_renderers.py +546 -0
- open_pharma_plugins_field_training/fixtures/sample_product_message.pdf +156 -0
- open_pharma_plugins_field_training/fixtures/sample_training_deck.pptx +0 -0
- open_pharma_plugins_field_training/models.py +265 -0
- open_pharma_plugins_field_training/tools/__init__.py +0 -0
- open_pharma_plugins_field_training/tools/get_document_page.py +67 -0
- open_pharma_plugins_field_training/tools/ingest_document.py +147 -0
- open_pharma_plugins_field_training/tools/list_documents.py +51 -0
- open_pharma_plugins_field_training/tools/render_output.py +118 -0
- open_pharma_plugins_field_training/tools/search_content.py +57 -0
- open_pharma_plugins_hcp_intelligence/__init__.py +14 -0
- open_pharma_plugins_hcp_intelligence/__main__.py +11 -0
- open_pharma_plugins_hcp_intelligence/_crm_store.py +70 -0
- open_pharma_plugins_hcp_intelligence/batch.py +891 -0
- open_pharma_plugins_hcp_intelligence/batch_cli.py +221 -0
- open_pharma_plugins_hcp_intelligence/batch_csv.py +206 -0
- open_pharma_plugins_hcp_intelligence/fixtures/sample_accounts.csv +27 -0
- open_pharma_plugins_hcp_intelligence/models.py +356 -0
- open_pharma_plugins_hcp_intelligence/tools/__init__.py +0 -0
- open_pharma_plugins_hcp_intelligence/tools/get_account.py +48 -0
- open_pharma_plugins_hcp_intelligence/tools/list_accounts.py +66 -0
- open_pharma_plugins_hcp_intelligence/tools/search_clinical_trials.py +175 -0
- open_pharma_plugins_hcp_intelligence/tools/search_congresses.py +134 -0
- open_pharma_plugins_hcp_intelligence/tools/search_grants.py +181 -0
- open_pharma_plugins_hcp_intelligence/tools/search_guidelines.py +238 -0
- open_pharma_plugins_hcp_intelligence/tools/search_hco_web.py +73 -0
- open_pharma_plugins_hcp_intelligence/tools/search_hcp_web.py +199 -0
- open_pharma_plugins_hcp_intelligence/tools/search_orcid.py +214 -0
- open_pharma_plugins_hcp_intelligence/tools/search_publications.py +207 -0
- open_pharma_plugins_hcp_intelligence/tools/update_account.py +84 -0
- open_pharma_plugins_next_best_engagement/__init__.py +14 -0
- open_pharma_plugins_next_best_engagement/__main__.py +11 -0
- open_pharma_plugins_next_best_engagement/_optimizer.py +400 -0
- open_pharma_plugins_next_best_engagement/_renderer.py +149 -0
- open_pharma_plugins_next_best_engagement/_scoring.py +82 -0
- open_pharma_plugins_next_best_engagement/_universe.py +145 -0
- open_pharma_plugins_next_best_engagement/fixtures/sample_universe.csv +81 -0
- open_pharma_plugins_next_best_engagement/models.py +135 -0
- open_pharma_plugins_next_best_engagement/tools/__init__.py +0 -0
- open_pharma_plugins_next_best_engagement/tools/load_universe.py +47 -0
- open_pharma_plugins_next_best_engagement/tools/recommend_engagements.py +80 -0
- open_pharma_plugins_next_best_engagement/tools/render_plan.py +90 -0
- open_pharma_plugins_territory_alignment/__init__.py +14 -0
- open_pharma_plugins_territory_alignment/__main__.py +11 -0
- open_pharma_plugins_territory_alignment/data.py +300 -0
- open_pharma_plugins_territory_alignment/fixtures/constraints.csv +11 -0
- open_pharma_plugins_territory_alignment/fixtures/current_alignment.csv +81 -0
- open_pharma_plugins_territory_alignment/fixtures/hcps.csv +81 -0
- open_pharma_plugins_territory_alignment/fixtures/reps.csv +9 -0
- open_pharma_plugins_territory_alignment/geo.py +175 -0
- open_pharma_plugins_territory_alignment/models.py +201 -0
- open_pharma_plugins_territory_alignment/scoring.py +125 -0
- open_pharma_plugins_territory_alignment/solver.py +504 -0
- open_pharma_plugins_territory_alignment/tools/__init__.py +0 -0
- open_pharma_plugins_territory_alignment/tools/ta_align.py +138 -0
- open_pharma_plugins_territory_alignment/tools/ta_cluster.py +247 -0
- open_pharma_plugins_territory_alignment/tools/ta_compare.py +173 -0
- open_pharma_plugins_territory_alignment/tools/ta_evaluate.py +119 -0
- open_pharma_plugins_territory_alignment/tools/ta_status.py +34 -0
- open_pharma_plugins_territory_alignment/tools/ta_visualize.py +504 -0
- shared/__init__.py +11 -0
- shared/env.py +217 -0
- shared/filesystem.py +110 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Credential-safe Serper, Tavily, and Exa news-search adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
from typing import Any, Callable, Mapping
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
from pydantic import ValidationError
|
|
11
|
+
|
|
12
|
+
from ._cache import cache_lookup, cache_store
|
|
13
|
+
from ._transport import HttpRequest, HttpTransport, TransportError, UrllibTransport
|
|
14
|
+
from .models import (
|
|
15
|
+
CacheProvenance,
|
|
16
|
+
CoverageStatus,
|
|
17
|
+
NewsItem,
|
|
18
|
+
NewsSearchRequest,
|
|
19
|
+
SearchBackend,
|
|
20
|
+
SourceError,
|
|
21
|
+
SourceName,
|
|
22
|
+
SourceRequestEvidence,
|
|
23
|
+
SourceResult,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
SERPER_ENDPOINT = "https://google.serper.dev/search"
|
|
27
|
+
TAVILY_ENDPOINT = "https://api.tavily.com/search"
|
|
28
|
+
EXA_ENDPOINT = "https://api.exa.ai/search"
|
|
29
|
+
DEFAULT_TRANSPORT: HttpTransport = UrllibTransport()
|
|
30
|
+
|
|
31
|
+
_BACKEND_CONFIG = {
|
|
32
|
+
SearchBackend.SERPER: ("SERPER_API_KEY", SERPER_ENDPOINT),
|
|
33
|
+
SearchBackend.TAVILY: ("TAVILY_API_KEY", TAVILY_ENDPOINT),
|
|
34
|
+
SearchBackend.EXA: ("EXA_API_KEY", EXA_ENDPOINT),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def search_news(
|
|
39
|
+
request: NewsSearchRequest,
|
|
40
|
+
*,
|
|
41
|
+
transport: HttpTransport | None = None,
|
|
42
|
+
now: datetime | None = None,
|
|
43
|
+
) -> SourceResult:
|
|
44
|
+
"""Search one configured backend and preserve failure/coverage semantics."""
|
|
45
|
+
from shared.env import get_env
|
|
46
|
+
|
|
47
|
+
retrieved_at = _utc(now)
|
|
48
|
+
backend, api_key = _select_backend(get_env)
|
|
49
|
+
if backend is None:
|
|
50
|
+
configured = (get_env("OPEN_PHARMA_SEARCH_BACKEND", "auto") or "auto").lower()
|
|
51
|
+
selected = configured if configured != SearchBackend.AUTO.value else "web_search"
|
|
52
|
+
endpoint = _BACKEND_CONFIG.get(_backend_or_none(configured), ("", SERPER_ENDPOINT))[1]
|
|
53
|
+
error = SourceError(
|
|
54
|
+
code="not_configured",
|
|
55
|
+
message=f"{selected} search backend is not configured",
|
|
56
|
+
)
|
|
57
|
+
evidence = SourceRequestEvidence(
|
|
58
|
+
query=request.query,
|
|
59
|
+
source_url=endpoint,
|
|
60
|
+
retrieved_at=retrieved_at,
|
|
61
|
+
cache=CacheProvenance(status="disabled"),
|
|
62
|
+
status=CoverageStatus.NOT_CONFIGURED,
|
|
63
|
+
record_count=0,
|
|
64
|
+
error=error,
|
|
65
|
+
)
|
|
66
|
+
return SourceResult(
|
|
67
|
+
source=SourceName.WEB,
|
|
68
|
+
provider=None if selected == "web_search" else selected,
|
|
69
|
+
status=CoverageStatus.NOT_CONFIGURED,
|
|
70
|
+
query=request.query,
|
|
71
|
+
source_url=endpoint,
|
|
72
|
+
retrieved_at=retrieved_at,
|
|
73
|
+
cache=CacheProvenance(status="disabled"),
|
|
74
|
+
requests=[evidence],
|
|
75
|
+
limitations=["No configured web-search provider was available."],
|
|
76
|
+
error=error,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
endpoint = _BACKEND_CONFIG[backend][1]
|
|
80
|
+
cache_params = {
|
|
81
|
+
"backend": backend.value,
|
|
82
|
+
"query": request.query,
|
|
83
|
+
"days_back": request.days_back,
|
|
84
|
+
"max_results": request.max_results,
|
|
85
|
+
}
|
|
86
|
+
lookup = cache_lookup("web_search", cache_params)
|
|
87
|
+
try:
|
|
88
|
+
payload = lookup.payload
|
|
89
|
+
if payload is None:
|
|
90
|
+
wire_request = _build_request(backend, api_key, request, retrieved_at)
|
|
91
|
+
response = (transport or DEFAULT_TRANSPORT).request(wire_request)
|
|
92
|
+
payload = _json_mapping(response.body)
|
|
93
|
+
records, dropped, total = _parse_results(backend, payload, request.max_results)
|
|
94
|
+
cache_store("web_search", cache_params, payload)
|
|
95
|
+
else:
|
|
96
|
+
if not isinstance(payload, Mapping):
|
|
97
|
+
raise ValueError("cached web-search payload must be a mapping")
|
|
98
|
+
records, dropped, total = _parse_results(backend, payload, request.max_results)
|
|
99
|
+
except TransportError as error:
|
|
100
|
+
return _failure(
|
|
101
|
+
request,
|
|
102
|
+
backend,
|
|
103
|
+
endpoint,
|
|
104
|
+
retrieved_at,
|
|
105
|
+
lookup.status,
|
|
106
|
+
error.code,
|
|
107
|
+
str(error),
|
|
108
|
+
)
|
|
109
|
+
except (json.JSONDecodeError, UnicodeDecodeError, ValidationError, ValueError, TypeError):
|
|
110
|
+
return _failure(
|
|
111
|
+
request,
|
|
112
|
+
backend,
|
|
113
|
+
endpoint,
|
|
114
|
+
retrieved_at,
|
|
115
|
+
lookup.status,
|
|
116
|
+
"schema_mismatch",
|
|
117
|
+
f"{backend.value} response shape was invalid",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
status = CoverageStatus.PARTIAL if dropped else CoverageStatus.COMPLETE
|
|
121
|
+
error = SourceError(code="schema_mismatch", message="some web-search results had invalid URLs") if dropped else None
|
|
122
|
+
evidence = SourceRequestEvidence(
|
|
123
|
+
query=request.query,
|
|
124
|
+
source_url=endpoint,
|
|
125
|
+
retrieved_at=retrieved_at,
|
|
126
|
+
cache=CacheProvenance(status=lookup.status, cached_at=lookup.cached_at),
|
|
127
|
+
status=status,
|
|
128
|
+
record_count=len(records),
|
|
129
|
+
error=error,
|
|
130
|
+
)
|
|
131
|
+
limitations = _window_limitations(backend, request.days_back)
|
|
132
|
+
if dropped:
|
|
133
|
+
limitations.append(f"Dropped {dropped} result(s) with invalid or incomplete URLs.")
|
|
134
|
+
return SourceResult(
|
|
135
|
+
source=SourceName.WEB,
|
|
136
|
+
provider=backend.value,
|
|
137
|
+
status=status,
|
|
138
|
+
query=request.query,
|
|
139
|
+
source_url=endpoint,
|
|
140
|
+
retrieved_at=retrieved_at,
|
|
141
|
+
cache=CacheProvenance(status=lookup.status, cached_at=lookup.cached_at),
|
|
142
|
+
records=records,
|
|
143
|
+
total_available=total,
|
|
144
|
+
requests=[evidence],
|
|
145
|
+
limitations=limitations,
|
|
146
|
+
error=error,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _select_backend(get_env: Callable[[str, str], str]) -> tuple[SearchBackend | None, str]:
|
|
151
|
+
value = (get_env("OPEN_PHARMA_SEARCH_BACKEND", "auto") or "auto").lower()
|
|
152
|
+
requested = _backend_or_none(value)
|
|
153
|
+
if value != SearchBackend.AUTO.value:
|
|
154
|
+
if requested is None:
|
|
155
|
+
return None, ""
|
|
156
|
+
key_name = _BACKEND_CONFIG[requested][0]
|
|
157
|
+
key = get_env(key_name, "")
|
|
158
|
+
return (requested, key) if key else (None, "")
|
|
159
|
+
for backend in (SearchBackend.SERPER, SearchBackend.TAVILY, SearchBackend.EXA):
|
|
160
|
+
key = get_env(_BACKEND_CONFIG[backend][0], "")
|
|
161
|
+
if key:
|
|
162
|
+
return backend, key
|
|
163
|
+
return None, ""
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _backend_or_none(value: str) -> SearchBackend | None:
|
|
167
|
+
try:
|
|
168
|
+
backend = SearchBackend(value)
|
|
169
|
+
except ValueError:
|
|
170
|
+
return None
|
|
171
|
+
return backend if backend != SearchBackend.AUTO else None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _build_request(
|
|
175
|
+
backend: SearchBackend,
|
|
176
|
+
api_key: str,
|
|
177
|
+
request: NewsSearchRequest,
|
|
178
|
+
now: datetime,
|
|
179
|
+
) -> HttpRequest:
|
|
180
|
+
endpoint = _BACKEND_CONFIG[backend][1]
|
|
181
|
+
if backend == SearchBackend.SERPER:
|
|
182
|
+
body: dict[str, Any] = {"q": request.query, "num": request.max_results}
|
|
183
|
+
period = _serper_period(request.days_back)
|
|
184
|
+
if period:
|
|
185
|
+
body["tbs"] = period
|
|
186
|
+
return HttpRequest(
|
|
187
|
+
method="POST",
|
|
188
|
+
url=endpoint,
|
|
189
|
+
headers={"X-API-KEY": api_key},
|
|
190
|
+
json_body=body,
|
|
191
|
+
)
|
|
192
|
+
if backend == SearchBackend.TAVILY:
|
|
193
|
+
body = {
|
|
194
|
+
"api_key": api_key,
|
|
195
|
+
"query": request.query,
|
|
196
|
+
"max_results": request.max_results,
|
|
197
|
+
"include_answer": False,
|
|
198
|
+
"time_range": _tavily_period(request.days_back),
|
|
199
|
+
}
|
|
200
|
+
return HttpRequest(method="POST", url=endpoint, json_body=body)
|
|
201
|
+
start = (now - timedelta(days=request.days_back)).strftime("%Y-%m-%dT00:00:00Z")
|
|
202
|
+
return HttpRequest(
|
|
203
|
+
method="POST",
|
|
204
|
+
url=endpoint,
|
|
205
|
+
headers={"x-api-key": api_key},
|
|
206
|
+
json_body={
|
|
207
|
+
"query": request.query,
|
|
208
|
+
"numResults": request.max_results,
|
|
209
|
+
"type": "neural",
|
|
210
|
+
"startPublishedDate": start,
|
|
211
|
+
"contents": {"text": {"maxCharacters": 300}},
|
|
212
|
+
},
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _parse_results(
|
|
217
|
+
backend: SearchBackend,
|
|
218
|
+
payload: Mapping[str, Any],
|
|
219
|
+
max_results: int,
|
|
220
|
+
) -> tuple[list[dict[str, Any]], int, int]:
|
|
221
|
+
key = "organic" if backend == SearchBackend.SERPER else "results"
|
|
222
|
+
items = payload.get(key, [])
|
|
223
|
+
if not isinstance(items, list):
|
|
224
|
+
raise ValueError(f"{key} must be a list")
|
|
225
|
+
records: list[dict[str, Any]] = []
|
|
226
|
+
dropped = 0
|
|
227
|
+
for raw in items[:max_results]:
|
|
228
|
+
if not isinstance(raw, Mapping):
|
|
229
|
+
dropped += 1
|
|
230
|
+
continue
|
|
231
|
+
try:
|
|
232
|
+
records.append(_parse_item(backend, raw).model_dump(mode="json"))
|
|
233
|
+
except ValidationError:
|
|
234
|
+
dropped += 1
|
|
235
|
+
if dropped and not records:
|
|
236
|
+
raise ValueError("web-search response contained no usable records")
|
|
237
|
+
return records, dropped, min(len(items), max_results)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _parse_item(backend: SearchBackend, item: Mapping[str, Any]) -> NewsItem:
|
|
241
|
+
url = str(item.get("link" if backend == SearchBackend.SERPER else "url", ""))
|
|
242
|
+
if backend == SearchBackend.TAVILY:
|
|
243
|
+
snippet = str(item.get("content", ""))[:300]
|
|
244
|
+
published = item.get("published_date")
|
|
245
|
+
elif backend == SearchBackend.EXA:
|
|
246
|
+
snippet = str(item.get("text", ""))[:300]
|
|
247
|
+
published = item.get("publishedDate")
|
|
248
|
+
else:
|
|
249
|
+
snippet = str(item.get("snippet", ""))[:300]
|
|
250
|
+
published = item.get("date")
|
|
251
|
+
return NewsItem(
|
|
252
|
+
title=str(item.get("title", "")),
|
|
253
|
+
url=url,
|
|
254
|
+
snippet=snippet,
|
|
255
|
+
source=str(item.get("source") or urlsplit(url).netloc),
|
|
256
|
+
published_date=str(published) if published else None,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _failure(
|
|
261
|
+
request: NewsSearchRequest,
|
|
262
|
+
backend: SearchBackend,
|
|
263
|
+
endpoint: str,
|
|
264
|
+
retrieved_at: datetime,
|
|
265
|
+
cache_status,
|
|
266
|
+
code: str,
|
|
267
|
+
message: str,
|
|
268
|
+
) -> SourceResult:
|
|
269
|
+
error = SourceError(code=code, message=message)
|
|
270
|
+
evidence = SourceRequestEvidence(
|
|
271
|
+
query=request.query,
|
|
272
|
+
source_url=endpoint,
|
|
273
|
+
retrieved_at=retrieved_at,
|
|
274
|
+
cache=CacheProvenance(status=cache_status),
|
|
275
|
+
status=CoverageStatus.FAILED,
|
|
276
|
+
record_count=0,
|
|
277
|
+
error=error,
|
|
278
|
+
)
|
|
279
|
+
return SourceResult(
|
|
280
|
+
source=SourceName.WEB,
|
|
281
|
+
provider=backend.value,
|
|
282
|
+
status=CoverageStatus.FAILED,
|
|
283
|
+
query=request.query,
|
|
284
|
+
source_url=endpoint,
|
|
285
|
+
retrieved_at=retrieved_at,
|
|
286
|
+
cache=CacheProvenance(status=cache_status),
|
|
287
|
+
requests=[evidence],
|
|
288
|
+
limitations=["No trustworthy web-search response was obtained."],
|
|
289
|
+
error=error,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _json_mapping(body: bytes) -> Mapping[str, Any]:
|
|
294
|
+
payload = json.loads(body.decode("utf-8"))
|
|
295
|
+
if not isinstance(payload, Mapping):
|
|
296
|
+
raise ValueError("provider response must be a mapping")
|
|
297
|
+
return payload
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _serper_period(days_back: int) -> str | None:
|
|
301
|
+
if days_back <= 1:
|
|
302
|
+
return "qdr:d"
|
|
303
|
+
if days_back <= 7:
|
|
304
|
+
return "qdr:w"
|
|
305
|
+
if days_back <= 30:
|
|
306
|
+
return "qdr:m"
|
|
307
|
+
return "qdr:y" if days_back <= 365 else None
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _tavily_period(days_back: int) -> str:
|
|
311
|
+
if days_back <= 1:
|
|
312
|
+
return "day"
|
|
313
|
+
if days_back <= 7:
|
|
314
|
+
return "week"
|
|
315
|
+
if days_back <= 30:
|
|
316
|
+
return "month"
|
|
317
|
+
return "year"
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _window_limitations(backend: SearchBackend, days_back: int) -> list[str]:
|
|
321
|
+
if backend == SearchBackend.EXA:
|
|
322
|
+
return []
|
|
323
|
+
bucket = _serper_period(days_back) if backend == SearchBackend.SERPER else _tavily_period(days_back)
|
|
324
|
+
return [f"{backend.value} applies coarse recency bucket {bucket!r} to the requested {days_back}-day window."]
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _utc(now: datetime | None) -> datetime:
|
|
328
|
+
value = now or datetime.now(timezone.utc)
|
|
329
|
+
if value.tzinfo is None:
|
|
330
|
+
value = value.replace(tzinfo=timezone.utc)
|
|
331
|
+
return value.astimezone(timezone.utc)
|