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,260 @@
|
|
|
1
|
+
"""DailyMed SPL search and history adapters with explicit evidence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any, Mapping
|
|
8
|
+
from urllib.parse import quote, urlencode
|
|
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
|
+
CacheStatus,
|
|
17
|
+
CoverageStatus,
|
|
18
|
+
LabelHistoryEntry,
|
|
19
|
+
RegulatorySearchRequest,
|
|
20
|
+
SourceError,
|
|
21
|
+
SourceName,
|
|
22
|
+
SourceRequestEvidence,
|
|
23
|
+
SourceResult,
|
|
24
|
+
aggregate_cache_status,
|
|
25
|
+
aggregate_coverage,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
_BASE = "https://dailymed.nlm.nih.gov/dailymed/services/v2"
|
|
29
|
+
DEFAULT_TRANSPORT: HttpTransport = UrllibTransport()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def search_dailymed(
|
|
33
|
+
request: RegulatorySearchRequest,
|
|
34
|
+
*,
|
|
35
|
+
transport: HttpTransport | None = None,
|
|
36
|
+
now: datetime | None = None,
|
|
37
|
+
) -> SourceResult:
|
|
38
|
+
transport = transport or DEFAULT_TRANSPORT
|
|
39
|
+
retrieved_at = _utc(now)
|
|
40
|
+
names = _distinct_names(request)
|
|
41
|
+
records: list[dict[str, Any]] = []
|
|
42
|
+
requests: list[SourceRequestEvidence] = []
|
|
43
|
+
cache_states: list[CacheStatus] = []
|
|
44
|
+
limitations: list[str] = []
|
|
45
|
+
for name in names:
|
|
46
|
+
params = {"drug_name": name, "pagesize": min(request.max_results, 100), "page": 1}
|
|
47
|
+
url = f"{_BASE}/spls.json?{urlencode(params)}"
|
|
48
|
+
lookup = cache_lookup("dailymed:search", params)
|
|
49
|
+
cache_states.append(lookup.status)
|
|
50
|
+
try:
|
|
51
|
+
payload = lookup.payload
|
|
52
|
+
if payload is None:
|
|
53
|
+
response = transport.request(HttpRequest(method="GET", url=url))
|
|
54
|
+
payload = _json_mapping(response.body)
|
|
55
|
+
cache_store("dailymed:search", params, payload)
|
|
56
|
+
parsed = _parse_search(payload)
|
|
57
|
+
records.extend(parsed)
|
|
58
|
+
requests.append(
|
|
59
|
+
SourceRequestEvidence(
|
|
60
|
+
query=name,
|
|
61
|
+
source_url=url,
|
|
62
|
+
retrieved_at=retrieved_at,
|
|
63
|
+
cache=CacheProvenance(status=lookup.status, cached_at=lookup.cached_at),
|
|
64
|
+
status=CoverageStatus.COMPLETE,
|
|
65
|
+
record_count=len(parsed),
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
except TransportError as error:
|
|
69
|
+
requests.append(_failed_request(name, url, retrieved_at, lookup, error.code, str(error)))
|
|
70
|
+
limitations.append(f"DailyMed search failed for {name}.")
|
|
71
|
+
except (ValueError, ValidationError, json.JSONDecodeError, UnicodeDecodeError):
|
|
72
|
+
requests.append(
|
|
73
|
+
_failed_request(
|
|
74
|
+
name,
|
|
75
|
+
url,
|
|
76
|
+
retrieved_at,
|
|
77
|
+
lookup,
|
|
78
|
+
"schema_mismatch",
|
|
79
|
+
"DailyMed search shape was invalid",
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
limitations.append(f"DailyMed search shape was invalid for {name}.")
|
|
83
|
+
|
|
84
|
+
status = aggregate_coverage([evidence.status for evidence in requests])
|
|
85
|
+
error = _aggregate_error(status, requests)
|
|
86
|
+
deduped = {str(record["set_id"]): record for record in records if record.get("set_id")}
|
|
87
|
+
return SourceResult(
|
|
88
|
+
source=SourceName.DAILYMED,
|
|
89
|
+
provider="dailymed",
|
|
90
|
+
status=status,
|
|
91
|
+
query=request.drug_name,
|
|
92
|
+
source_url=f"{_BASE}/spls.json?{urlencode({'drug_name': request.drug_name})}",
|
|
93
|
+
retrieved_at=retrieved_at,
|
|
94
|
+
cache=CacheProvenance(status=aggregate_cache_status(cache_states)),
|
|
95
|
+
records=list(deduped.values()) if status in {CoverageStatus.COMPLETE, CoverageStatus.PARTIAL} else [],
|
|
96
|
+
total_available=len(deduped) if status in {CoverageStatus.COMPLETE, CoverageStatus.PARTIAL} else None,
|
|
97
|
+
requests=requests,
|
|
98
|
+
limitations=limitations,
|
|
99
|
+
error=error,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def get_dailymed_history(
|
|
104
|
+
set_id: str,
|
|
105
|
+
*,
|
|
106
|
+
transport: HttpTransport | None = None,
|
|
107
|
+
now: datetime | None = None,
|
|
108
|
+
) -> SourceResult:
|
|
109
|
+
transport = transport or DEFAULT_TRANSPORT
|
|
110
|
+
retrieved_at = _utc(now)
|
|
111
|
+
encoded_set_id = quote(set_id, safe="")
|
|
112
|
+
url = f"{_BASE}/spls/{encoded_set_id}/history.json"
|
|
113
|
+
lookup = cache_lookup("dailymed:history", {"set_id": set_id})
|
|
114
|
+
try:
|
|
115
|
+
payload = lookup.payload
|
|
116
|
+
if payload is None:
|
|
117
|
+
response = transport.request(HttpRequest(method="GET", url=url))
|
|
118
|
+
payload = _json_mapping(response.body)
|
|
119
|
+
cache_store("dailymed:history", {"set_id": set_id}, payload)
|
|
120
|
+
records = _parse_history(payload, source_url=url)
|
|
121
|
+
except TransportError as error:
|
|
122
|
+
return _history_failure(set_id, url, retrieved_at, lookup, error.code, str(error))
|
|
123
|
+
except (ValueError, ValidationError, json.JSONDecodeError, UnicodeDecodeError):
|
|
124
|
+
return _history_failure(
|
|
125
|
+
set_id,
|
|
126
|
+
url,
|
|
127
|
+
retrieved_at,
|
|
128
|
+
lookup,
|
|
129
|
+
"schema_mismatch",
|
|
130
|
+
"DailyMed history shape was invalid",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
evidence = SourceRequestEvidence(
|
|
134
|
+
query=set_id,
|
|
135
|
+
source_url=url,
|
|
136
|
+
retrieved_at=retrieved_at,
|
|
137
|
+
cache=CacheProvenance(status=lookup.status, cached_at=lookup.cached_at),
|
|
138
|
+
status=CoverageStatus.COMPLETE,
|
|
139
|
+
record_count=len(records),
|
|
140
|
+
)
|
|
141
|
+
return SourceResult(
|
|
142
|
+
source=SourceName.DAILYMED,
|
|
143
|
+
provider="dailymed",
|
|
144
|
+
status=CoverageStatus.COMPLETE,
|
|
145
|
+
query=set_id,
|
|
146
|
+
source_url=url,
|
|
147
|
+
retrieved_at=retrieved_at,
|
|
148
|
+
cache=CacheProvenance(status=lookup.status, cached_at=lookup.cached_at),
|
|
149
|
+
records=records,
|
|
150
|
+
total_available=len(records),
|
|
151
|
+
requests=[evidence],
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _parse_search(payload: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
156
|
+
data = payload.get("data")
|
|
157
|
+
if not isinstance(data, list):
|
|
158
|
+
raise ValueError("DailyMed data must be a list")
|
|
159
|
+
records = []
|
|
160
|
+
for item in data:
|
|
161
|
+
if not isinstance(item, Mapping):
|
|
162
|
+
raise ValueError("DailyMed SPL must be a mapping")
|
|
163
|
+
set_id = str(item.get("setid", ""))
|
|
164
|
+
if not set_id:
|
|
165
|
+
raise ValueError("DailyMed SPL requires setid")
|
|
166
|
+
records.append(
|
|
167
|
+
{
|
|
168
|
+
"set_id": set_id,
|
|
169
|
+
"spl_version": str(item.get("spl_version", "")),
|
|
170
|
+
"title": str(item.get("title", "")),
|
|
171
|
+
"published_date": str(item.get("published_date", "")),
|
|
172
|
+
"source_url": f"https://dailymed.nlm.nih.gov/dailymed/drugInfo.cfm?setid={quote(set_id)}",
|
|
173
|
+
}
|
|
174
|
+
)
|
|
175
|
+
return records
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _parse_history(payload: Mapping[str, Any], *, source_url: str) -> list[dict[str, Any]]:
|
|
179
|
+
data = payload.get("data")
|
|
180
|
+
if not isinstance(data, Mapping):
|
|
181
|
+
raise ValueError("DailyMed history data must be a mapping")
|
|
182
|
+
history = data.get("history")
|
|
183
|
+
spl = data.get("spl")
|
|
184
|
+
if not isinstance(history, list) or not isinstance(spl, Mapping):
|
|
185
|
+
raise ValueError("DailyMed history and spl containers are required")
|
|
186
|
+
set_id = str(spl.get("setid", ""))
|
|
187
|
+
title = str(spl.get("title", ""))
|
|
188
|
+
if not set_id:
|
|
189
|
+
raise ValueError("DailyMed SPL requires setid")
|
|
190
|
+
return [
|
|
191
|
+
LabelHistoryEntry(
|
|
192
|
+
set_id=set_id,
|
|
193
|
+
spl_version=str(item.get("spl_version", "")),
|
|
194
|
+
published_date=str(item.get("published_date", "")),
|
|
195
|
+
title=title,
|
|
196
|
+
source_url=source_url,
|
|
197
|
+
).model_dump(mode="json")
|
|
198
|
+
for item in history
|
|
199
|
+
if isinstance(item, Mapping)
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _failed_request(name, url, retrieved_at, lookup, code, message) -> SourceRequestEvidence:
|
|
204
|
+
return SourceRequestEvidence(
|
|
205
|
+
query=name,
|
|
206
|
+
source_url=url,
|
|
207
|
+
retrieved_at=retrieved_at,
|
|
208
|
+
cache=CacheProvenance(status=lookup.status, cached_at=lookup.cached_at),
|
|
209
|
+
status=CoverageStatus.FAILED,
|
|
210
|
+
record_count=0,
|
|
211
|
+
error=SourceError(code=code, message=message),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _aggregate_error(status: CoverageStatus, requests: list[SourceRequestEvidence]) -> SourceError | None:
|
|
216
|
+
if status == CoverageStatus.PARTIAL:
|
|
217
|
+
return SourceError(code="partial_coverage", message="one or more DailyMed searches failed")
|
|
218
|
+
if status == CoverageStatus.FAILED:
|
|
219
|
+
return next((request.error for request in requests if request.error is not None), None)
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _history_failure(set_id, url, retrieved_at, lookup, code, message) -> SourceResult:
|
|
224
|
+
error = SourceError(code=code, message=message)
|
|
225
|
+
evidence = _failed_request(set_id, url, retrieved_at, lookup, code, message)
|
|
226
|
+
return SourceResult(
|
|
227
|
+
source=SourceName.DAILYMED,
|
|
228
|
+
provider="dailymed",
|
|
229
|
+
status=CoverageStatus.FAILED,
|
|
230
|
+
query=set_id,
|
|
231
|
+
source_url=url,
|
|
232
|
+
retrieved_at=retrieved_at,
|
|
233
|
+
cache=CacheProvenance(status=lookup.status, cached_at=lookup.cached_at),
|
|
234
|
+
requests=[evidence],
|
|
235
|
+
limitations=["No trustworthy DailyMed history response was obtained."],
|
|
236
|
+
error=error,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _distinct_names(request: RegulatorySearchRequest) -> list[str]:
|
|
241
|
+
names = []
|
|
242
|
+
for value in [request.drug_name, *request.aliases]:
|
|
243
|
+
normalized = value.strip()
|
|
244
|
+
if normalized and normalized.casefold() not in {name.casefold() for name in names}:
|
|
245
|
+
names.append(normalized)
|
|
246
|
+
return names
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _json_mapping(body: bytes) -> Mapping[str, Any]:
|
|
250
|
+
payload = json.loads(body.decode("utf-8"))
|
|
251
|
+
if not isinstance(payload, Mapping):
|
|
252
|
+
raise ValueError("provider response must be a mapping")
|
|
253
|
+
return payload
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _utc(value: datetime | None) -> datetime:
|
|
257
|
+
now = value or datetime.now(timezone.utc)
|
|
258
|
+
if now.tzinfo is None:
|
|
259
|
+
raise ValueError("now must be timezone-aware")
|
|
260
|
+
return now.astimezone(timezone.utc)
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""openFDA Drugs@FDA adapter with explicit evidence and identity fields."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any, Mapping
|
|
8
|
+
from urllib.parse import urlencode
|
|
9
|
+
|
|
10
|
+
from pydantic import ValidationError
|
|
11
|
+
|
|
12
|
+
from shared.filesystem import sanitize_url
|
|
13
|
+
|
|
14
|
+
from ._cache import cache_lookup, cache_store
|
|
15
|
+
from ._transport import HttpRequest, HttpTransport, TransportError, UrllibTransport
|
|
16
|
+
from .models import (
|
|
17
|
+
CacheProvenance,
|
|
18
|
+
CoverageStatus,
|
|
19
|
+
RegulatoryEvent,
|
|
20
|
+
RegulatorySearchRequest,
|
|
21
|
+
SourceError,
|
|
22
|
+
SourceName,
|
|
23
|
+
SourceRequestEvidence,
|
|
24
|
+
SourceResult,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
_ENDPOINT = "https://api.fda.gov/drug/drugsfda.json"
|
|
28
|
+
_SAFE_QUERY_CHARS = '():[]"'
|
|
29
|
+
DEFAULT_TRANSPORT: HttpTransport = UrllibTransport()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_openfda_url(request: RegulatorySearchRequest, *, api_key: str) -> str:
|
|
33
|
+
params = {"search": _build_query(request), "limit": str(request.max_results)}
|
|
34
|
+
if api_key:
|
|
35
|
+
params["api_key"] = api_key
|
|
36
|
+
return f"{_ENDPOINT}?{urlencode(params, safe=_SAFE_QUERY_CHARS)}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def search_openfda(
|
|
40
|
+
request: RegulatorySearchRequest,
|
|
41
|
+
*,
|
|
42
|
+
transport: HttpTransport | None = None,
|
|
43
|
+
now: datetime | None = None,
|
|
44
|
+
) -> SourceResult:
|
|
45
|
+
from shared.env import get_env
|
|
46
|
+
|
|
47
|
+
transport = transport or DEFAULT_TRANSPORT
|
|
48
|
+
retrieved_at = _utc(now)
|
|
49
|
+
api_key = get_env("OPENFDA_API_KEY", "")
|
|
50
|
+
wire_url = build_openfda_url(request, api_key=api_key)
|
|
51
|
+
evidence_url = sanitize_url(wire_url)
|
|
52
|
+
cache_params = {
|
|
53
|
+
"search": _build_query(request),
|
|
54
|
+
"limit": request.max_results,
|
|
55
|
+
}
|
|
56
|
+
lookup = cache_lookup("openfda:drugsfda", cache_params)
|
|
57
|
+
try:
|
|
58
|
+
payload = lookup.payload
|
|
59
|
+
if payload is None:
|
|
60
|
+
response = transport.request(HttpRequest(method="GET", url=wire_url))
|
|
61
|
+
payload = _json_mapping(response.body)
|
|
62
|
+
cache_store("openfda:drugsfda", cache_params, payload)
|
|
63
|
+
records, dropped = _parse_response(payload, evidence_url)
|
|
64
|
+
except TransportError as error:
|
|
65
|
+
return _failure(
|
|
66
|
+
request=request,
|
|
67
|
+
source_url=evidence_url,
|
|
68
|
+
retrieved_at=retrieved_at,
|
|
69
|
+
cache=CacheProvenance(status=lookup.status, cached_at=lookup.cached_at),
|
|
70
|
+
error=SourceError(code=error.code, message=str(error)),
|
|
71
|
+
)
|
|
72
|
+
except (ValueError, ValidationError, json.JSONDecodeError, UnicodeDecodeError):
|
|
73
|
+
return _failure(
|
|
74
|
+
request=request,
|
|
75
|
+
source_url=evidence_url,
|
|
76
|
+
retrieved_at=retrieved_at,
|
|
77
|
+
cache=CacheProvenance(status=lookup.status, cached_at=lookup.cached_at),
|
|
78
|
+
error=SourceError(code="schema_mismatch", message="openFDA response shape was invalid"),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
if dropped and not records:
|
|
82
|
+
return _failure(
|
|
83
|
+
request=request,
|
|
84
|
+
source_url=evidence_url,
|
|
85
|
+
retrieved_at=retrieved_at,
|
|
86
|
+
cache=CacheProvenance(status=lookup.status, cached_at=lookup.cached_at),
|
|
87
|
+
error=SourceError(code="schema_mismatch", message="openFDA returned no valid records"),
|
|
88
|
+
)
|
|
89
|
+
status = CoverageStatus.PARTIAL if dropped else CoverageStatus.COMPLETE
|
|
90
|
+
error = SourceError(code="schema_mismatch", message="some openFDA records were invalid") if dropped else None
|
|
91
|
+
limitation = [f"Dropped {dropped} malformed openFDA record(s)."] if dropped else []
|
|
92
|
+
request_evidence = SourceRequestEvidence(
|
|
93
|
+
query=_build_query(request),
|
|
94
|
+
source_url=evidence_url,
|
|
95
|
+
retrieved_at=retrieved_at,
|
|
96
|
+
cache=CacheProvenance(status=lookup.status, cached_at=lookup.cached_at),
|
|
97
|
+
status=status,
|
|
98
|
+
record_count=len(records),
|
|
99
|
+
error=error,
|
|
100
|
+
)
|
|
101
|
+
return SourceResult(
|
|
102
|
+
source=SourceName.OPENFDA,
|
|
103
|
+
provider="openfda",
|
|
104
|
+
status=status,
|
|
105
|
+
query=_build_query(request),
|
|
106
|
+
source_url=evidence_url,
|
|
107
|
+
retrieved_at=retrieved_at,
|
|
108
|
+
cache=CacheProvenance(status=lookup.status, cached_at=lookup.cached_at),
|
|
109
|
+
records=records,
|
|
110
|
+
requests=[request_evidence],
|
|
111
|
+
limitations=limitation,
|
|
112
|
+
error=error,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _build_query(request: RegulatorySearchRequest) -> str:
|
|
117
|
+
names = []
|
|
118
|
+
for value in [request.drug_name, *request.aliases]:
|
|
119
|
+
clean = value.replace('"', "").replace("\\", "").strip()
|
|
120
|
+
if clean and clean.casefold() not in {name.casefold() for name in names}:
|
|
121
|
+
names.append(clean)
|
|
122
|
+
name_groups = [f'(openfda.brand_name:"{name}" OR openfda.generic_name:"{name}")' for name in names]
|
|
123
|
+
query = f"({' OR '.join(name_groups)})" if len(name_groups) > 1 else name_groups[0]
|
|
124
|
+
if request.date_from or request.date_to:
|
|
125
|
+
start = request.date_from.strftime("%Y%m%d") if request.date_from else "00010101"
|
|
126
|
+
end = request.date_to.strftime("%Y%m%d") if request.date_to else "99991231"
|
|
127
|
+
query += f" AND submissions.submission_status_date:[{start} TO {end}]"
|
|
128
|
+
return query
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _parse_response(payload: Mapping[str, Any], source_url: str) -> tuple[list[dict[str, Any]], int]:
|
|
132
|
+
results = payload.get("results")
|
|
133
|
+
if not isinstance(results, list):
|
|
134
|
+
raise ValueError("results must be a list")
|
|
135
|
+
records = []
|
|
136
|
+
dropped = 0
|
|
137
|
+
for result in results:
|
|
138
|
+
try:
|
|
139
|
+
if not isinstance(result, Mapping):
|
|
140
|
+
raise ValueError("result must be a mapping")
|
|
141
|
+
openfda = result.get("openfda") or {}
|
|
142
|
+
if not isinstance(openfda, Mapping):
|
|
143
|
+
raise ValueError("openfda must be a mapping")
|
|
144
|
+
submissions = result.get("submissions") or []
|
|
145
|
+
if not isinstance(submissions, list):
|
|
146
|
+
raise ValueError("submissions must be a list")
|
|
147
|
+
for submission in submissions:
|
|
148
|
+
if not isinstance(submission, Mapping):
|
|
149
|
+
raise ValueError("submission must be a mapping")
|
|
150
|
+
event = RegulatoryEvent(
|
|
151
|
+
date=_submission_date(submission.get("submission_status_date")),
|
|
152
|
+
event_type=_classify_submission(str(submission.get("submission_type", ""))),
|
|
153
|
+
application_number=str(result.get("application_number", "")),
|
|
154
|
+
submission=(f"{submission.get('submission_type', '')}-{submission.get('submission_number', '')}"),
|
|
155
|
+
status=str(submission.get("submission_status", "")),
|
|
156
|
+
brand_name=_first(openfda.get("brand_name")),
|
|
157
|
+
generic_name=_first(openfda.get("generic_name")),
|
|
158
|
+
sponsor=str(result.get("sponsor_name", "")),
|
|
159
|
+
manufacturer_names=_strings(openfda.get("manufacturer_name")),
|
|
160
|
+
description=_submission_description(submission),
|
|
161
|
+
source_url=source_url,
|
|
162
|
+
)
|
|
163
|
+
records.append(event.model_dump(mode="json"))
|
|
164
|
+
except (ValueError, ValidationError, TypeError):
|
|
165
|
+
dropped += 1
|
|
166
|
+
return records, dropped
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _classify_submission(submission_type: str) -> str:
|
|
170
|
+
normalized = submission_type.upper()
|
|
171
|
+
if normalized == "ORIG":
|
|
172
|
+
return "approval"
|
|
173
|
+
if normalized == "SUPPL":
|
|
174
|
+
return "supplement"
|
|
175
|
+
if normalized in {"EFFSUPL", "EFFICACY SUPPL"}:
|
|
176
|
+
return "label_change"
|
|
177
|
+
return "other"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _submission_description(submission: Mapping[str, Any]) -> str:
|
|
181
|
+
parts = []
|
|
182
|
+
classification = str(submission.get("submission_class_code_description", ""))
|
|
183
|
+
submission_type = str(submission.get("submission_type", ""))
|
|
184
|
+
status = str(submission.get("submission_status", ""))
|
|
185
|
+
if classification:
|
|
186
|
+
parts.append(classification)
|
|
187
|
+
elif submission_type:
|
|
188
|
+
parts.append(submission_type)
|
|
189
|
+
if status:
|
|
190
|
+
parts.append(f"({status})")
|
|
191
|
+
priority = str(submission.get("review_priority", ""))
|
|
192
|
+
if priority:
|
|
193
|
+
parts.append(f"[{priority}]")
|
|
194
|
+
return " ".join(parts)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _submission_date(value: Any) -> str | None:
|
|
198
|
+
text = str(value or "")
|
|
199
|
+
if len(text) != 8 or not text.isdigit():
|
|
200
|
+
return None
|
|
201
|
+
return f"{text[:4]}-{text[4:6]}-{text[6:8]}"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _strings(value: Any) -> list[str]:
|
|
205
|
+
return [str(item) for item in value] if isinstance(value, list) else []
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _first(value: Any) -> str:
|
|
209
|
+
values = _strings(value)
|
|
210
|
+
return values[0] if values else ""
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _failure(
|
|
214
|
+
*,
|
|
215
|
+
request: RegulatorySearchRequest,
|
|
216
|
+
source_url: str,
|
|
217
|
+
retrieved_at: datetime,
|
|
218
|
+
cache: CacheProvenance,
|
|
219
|
+
error: SourceError,
|
|
220
|
+
) -> SourceResult:
|
|
221
|
+
evidence = SourceRequestEvidence(
|
|
222
|
+
query=_build_query(request),
|
|
223
|
+
source_url=source_url,
|
|
224
|
+
retrieved_at=retrieved_at,
|
|
225
|
+
cache=cache,
|
|
226
|
+
status=CoverageStatus.FAILED,
|
|
227
|
+
record_count=0,
|
|
228
|
+
error=error,
|
|
229
|
+
)
|
|
230
|
+
return SourceResult(
|
|
231
|
+
source=SourceName.OPENFDA,
|
|
232
|
+
provider="openfda",
|
|
233
|
+
status=CoverageStatus.FAILED,
|
|
234
|
+
query=_build_query(request),
|
|
235
|
+
source_url=source_url,
|
|
236
|
+
retrieved_at=retrieved_at,
|
|
237
|
+
cache=cache,
|
|
238
|
+
requests=[evidence],
|
|
239
|
+
limitations=["No trustworthy openFDA response was obtained."],
|
|
240
|
+
error=error,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _json_mapping(body: bytes) -> Mapping[str, Any]:
|
|
245
|
+
payload = json.loads(body.decode("utf-8"))
|
|
246
|
+
if not isinstance(payload, Mapping):
|
|
247
|
+
raise ValueError("provider response must be a mapping")
|
|
248
|
+
return payload
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _utc(value: datetime | None) -> datetime:
|
|
252
|
+
now = value or datetime.now(timezone.utc)
|
|
253
|
+
if now.tzinfo is None:
|
|
254
|
+
raise ValueError("now must be timezone-aware")
|
|
255
|
+
return now.astimezone(timezone.utc)
|