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,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)