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.
Files changed (136) hide show
  1. mcp_framework.py +495 -0
  2. open_pharma_plugins-2.2.0.dist-info/METADATA +135 -0
  3. open_pharma_plugins-2.2.0.dist-info/RECORD +136 -0
  4. open_pharma_plugins-2.2.0.dist-info/WHEEL +5 -0
  5. open_pharma_plugins-2.2.0.dist-info/entry_points.txt +8 -0
  6. open_pharma_plugins-2.2.0.dist-info/licenses/LICENSE +202 -0
  7. open_pharma_plugins-2.2.0.dist-info/top_level.txt +8 -0
  8. open_pharma_plugins_campaign_studio/__init__.py +14 -0
  9. open_pharma_plugins_campaign_studio/__main__.py +11 -0
  10. open_pharma_plugins_campaign_studio/_campaign_store.py +162 -0
  11. open_pharma_plugins_campaign_studio/_claim_engine.py +262 -0
  12. open_pharma_plugins_campaign_studio/_renderer.py +119 -0
  13. open_pharma_plugins_campaign_studio/fixtures/brand_kit/legal.json +21 -0
  14. open_pharma_plugins_campaign_studio/fixtures/brand_kit/logo.svg +4 -0
  15. open_pharma_plugins_campaign_studio/fixtures/brand_kit/palette.json +11 -0
  16. open_pharma_plugins_campaign_studio/fixtures/brand_kit/product.png +1 -0
  17. open_pharma_plugins_campaign_studio/fixtures/brand_kit/typography.json +14 -0
  18. open_pharma_plugins_campaign_studio/fixtures/sample_approved_claims.json +119 -0
  19. open_pharma_plugins_campaign_studio/models/__init__.py +34 -0
  20. open_pharma_plugins_campaign_studio/models/_common.py +12 -0
  21. open_pharma_plugins_campaign_studio/models/brief.py +72 -0
  22. open_pharma_plugins_campaign_studio/models/claims.py +15 -0
  23. open_pharma_plugins_campaign_studio/models/copy.py +47 -0
  24. open_pharma_plugins_campaign_studio/models/journey.py +21 -0
  25. open_pharma_plugins_campaign_studio/models/message.py +25 -0
  26. open_pharma_plugins_campaign_studio/models/mlr.py +29 -0
  27. open_pharma_plugins_campaign_studio/models/validation.py +32 -0
  28. open_pharma_plugins_campaign_studio/policy/rules.json +79 -0
  29. open_pharma_plugins_campaign_studio/templates/banner.svg.j2 +26 -0
  30. open_pharma_plugins_campaign_studio/templates/email.html.j2 +54 -0
  31. open_pharma_plugins_campaign_studio/tools/__init__.py +0 -0
  32. open_pharma_plugins_campaign_studio/tools/create_campaign_brief.py +212 -0
  33. open_pharma_plugins_campaign_studio/tools/generate_audience_journey.py +129 -0
  34. open_pharma_plugins_campaign_studio/tools/generate_channel_copy.py +199 -0
  35. open_pharma_plugins_campaign_studio/tools/generate_message_architecture.py +121 -0
  36. open_pharma_plugins_campaign_studio/tools/package_mlr_submission.py +230 -0
  37. open_pharma_plugins_campaign_studio/tools/render_banner.py +99 -0
  38. open_pharma_plugins_campaign_studio/tools/render_email.py +101 -0
  39. open_pharma_plugins_campaign_studio/tools/render_poster.py +222 -0
  40. open_pharma_plugins_campaign_studio/tools/retrieve_approved_claims.py +71 -0
  41. open_pharma_plugins_campaign_studio/tools/retrieve_brand_components.py +76 -0
  42. open_pharma_plugins_campaign_studio/tools/validate_claims_and_fair_balance.py +285 -0
  43. open_pharma_plugins_competitive_intelligence/__init__.py +13 -0
  44. open_pharma_plugins_competitive_intelligence/__main__.py +11 -0
  45. open_pharma_plugins_competitive_intelligence/_artifacts.py +87 -0
  46. open_pharma_plugins_competitive_intelligence/_cache.py +144 -0
  47. open_pharma_plugins_competitive_intelligence/_clinical_trials.py +569 -0
  48. open_pharma_plugins_competitive_intelligence/_dailymed.py +260 -0
  49. open_pharma_plugins_competitive_intelligence/_fda.py +255 -0
  50. open_pharma_plugins_competitive_intelligence/_pubmed.py +342 -0
  51. open_pharma_plugins_competitive_intelligence/_regulatory.py +140 -0
  52. open_pharma_plugins_competitive_intelligence/_runs.py +278 -0
  53. open_pharma_plugins_competitive_intelligence/_transport.py +83 -0
  54. open_pharma_plugins_competitive_intelligence/_watchlist.py +113 -0
  55. open_pharma_plugins_competitive_intelligence/_web_search.py +331 -0
  56. open_pharma_plugins_competitive_intelligence/models.py +525 -0
  57. open_pharma_plugins_competitive_intelligence/tools/__init__.py +0 -0
  58. open_pharma_plugins_competitive_intelligence/tools/ci_extract_events.py +247 -0
  59. open_pharma_plugins_competitive_intelligence/tools/ci_landscape.py +195 -0
  60. open_pharma_plugins_competitive_intelligence/tools/ci_refresh.py +101 -0
  61. open_pharma_plugins_competitive_intelligence/tools/ci_report.py +402 -0
  62. open_pharma_plugins_competitive_intelligence/tools/ci_scan_news.py +48 -0
  63. open_pharma_plugins_competitive_intelligence/tools/ci_scan_publications.py +46 -0
  64. open_pharma_plugins_competitive_intelligence/tools/ci_scan_regulatory.py +64 -0
  65. open_pharma_plugins_competitive_intelligence/tools/ci_scan_trials.py +85 -0
  66. open_pharma_plugins_competitive_intelligence/tools/ci_status.py +106 -0
  67. open_pharma_plugins_competitive_intelligence/tools/ci_timeline.py +453 -0
  68. open_pharma_plugins_competitive_intelligence/tools/ci_track.py +121 -0
  69. open_pharma_plugins_competitive_intelligence/tools/ci_trial_detail.py +55 -0
  70. open_pharma_plugins_field_training/__init__.py +13 -0
  71. open_pharma_plugins_field_training/__main__.py +11 -0
  72. open_pharma_plugins_field_training/_content_store.py +131 -0
  73. open_pharma_plugins_field_training/_grounding.py +75 -0
  74. open_pharma_plugins_field_training/_html_renderers.py +546 -0
  75. open_pharma_plugins_field_training/fixtures/sample_product_message.pdf +156 -0
  76. open_pharma_plugins_field_training/fixtures/sample_training_deck.pptx +0 -0
  77. open_pharma_plugins_field_training/models.py +265 -0
  78. open_pharma_plugins_field_training/tools/__init__.py +0 -0
  79. open_pharma_plugins_field_training/tools/get_document_page.py +67 -0
  80. open_pharma_plugins_field_training/tools/ingest_document.py +147 -0
  81. open_pharma_plugins_field_training/tools/list_documents.py +51 -0
  82. open_pharma_plugins_field_training/tools/render_output.py +118 -0
  83. open_pharma_plugins_field_training/tools/search_content.py +57 -0
  84. open_pharma_plugins_hcp_intelligence/__init__.py +14 -0
  85. open_pharma_plugins_hcp_intelligence/__main__.py +11 -0
  86. open_pharma_plugins_hcp_intelligence/_crm_store.py +70 -0
  87. open_pharma_plugins_hcp_intelligence/batch.py +891 -0
  88. open_pharma_plugins_hcp_intelligence/batch_cli.py +221 -0
  89. open_pharma_plugins_hcp_intelligence/batch_csv.py +206 -0
  90. open_pharma_plugins_hcp_intelligence/fixtures/sample_accounts.csv +27 -0
  91. open_pharma_plugins_hcp_intelligence/models.py +356 -0
  92. open_pharma_plugins_hcp_intelligence/tools/__init__.py +0 -0
  93. open_pharma_plugins_hcp_intelligence/tools/get_account.py +48 -0
  94. open_pharma_plugins_hcp_intelligence/tools/list_accounts.py +66 -0
  95. open_pharma_plugins_hcp_intelligence/tools/search_clinical_trials.py +175 -0
  96. open_pharma_plugins_hcp_intelligence/tools/search_congresses.py +134 -0
  97. open_pharma_plugins_hcp_intelligence/tools/search_grants.py +181 -0
  98. open_pharma_plugins_hcp_intelligence/tools/search_guidelines.py +238 -0
  99. open_pharma_plugins_hcp_intelligence/tools/search_hco_web.py +73 -0
  100. open_pharma_plugins_hcp_intelligence/tools/search_hcp_web.py +199 -0
  101. open_pharma_plugins_hcp_intelligence/tools/search_orcid.py +214 -0
  102. open_pharma_plugins_hcp_intelligence/tools/search_publications.py +207 -0
  103. open_pharma_plugins_hcp_intelligence/tools/update_account.py +84 -0
  104. open_pharma_plugins_next_best_engagement/__init__.py +14 -0
  105. open_pharma_plugins_next_best_engagement/__main__.py +11 -0
  106. open_pharma_plugins_next_best_engagement/_optimizer.py +400 -0
  107. open_pharma_plugins_next_best_engagement/_renderer.py +149 -0
  108. open_pharma_plugins_next_best_engagement/_scoring.py +82 -0
  109. open_pharma_plugins_next_best_engagement/_universe.py +145 -0
  110. open_pharma_plugins_next_best_engagement/fixtures/sample_universe.csv +81 -0
  111. open_pharma_plugins_next_best_engagement/models.py +135 -0
  112. open_pharma_plugins_next_best_engagement/tools/__init__.py +0 -0
  113. open_pharma_plugins_next_best_engagement/tools/load_universe.py +47 -0
  114. open_pharma_plugins_next_best_engagement/tools/recommend_engagements.py +80 -0
  115. open_pharma_plugins_next_best_engagement/tools/render_plan.py +90 -0
  116. open_pharma_plugins_territory_alignment/__init__.py +14 -0
  117. open_pharma_plugins_territory_alignment/__main__.py +11 -0
  118. open_pharma_plugins_territory_alignment/data.py +300 -0
  119. open_pharma_plugins_territory_alignment/fixtures/constraints.csv +11 -0
  120. open_pharma_plugins_territory_alignment/fixtures/current_alignment.csv +81 -0
  121. open_pharma_plugins_territory_alignment/fixtures/hcps.csv +81 -0
  122. open_pharma_plugins_territory_alignment/fixtures/reps.csv +9 -0
  123. open_pharma_plugins_territory_alignment/geo.py +175 -0
  124. open_pharma_plugins_territory_alignment/models.py +201 -0
  125. open_pharma_plugins_territory_alignment/scoring.py +125 -0
  126. open_pharma_plugins_territory_alignment/solver.py +504 -0
  127. open_pharma_plugins_territory_alignment/tools/__init__.py +0 -0
  128. open_pharma_plugins_territory_alignment/tools/ta_align.py +138 -0
  129. open_pharma_plugins_territory_alignment/tools/ta_cluster.py +247 -0
  130. open_pharma_plugins_territory_alignment/tools/ta_compare.py +173 -0
  131. open_pharma_plugins_territory_alignment/tools/ta_evaluate.py +119 -0
  132. open_pharma_plugins_territory_alignment/tools/ta_status.py +34 -0
  133. open_pharma_plugins_territory_alignment/tools/ta_visualize.py +504 -0
  134. shared/__init__.py +11 -0
  135. shared/env.py +217 -0
  136. 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)