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,278 @@
1
+ """One-pass collection and immutable persistence for CI evidence runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import secrets
9
+ import shutil
10
+ import tempfile
11
+ from dataclasses import dataclass
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Callable
15
+
16
+ from pydantic import ValidationError
17
+
18
+ from shared.filesystem import atomic_write_text, contained_path, validate_component
19
+
20
+ from . import _clinical_trials, _pubmed, _regulatory, _web_search
21
+ from ._watchlist import runs_dir
22
+ from .models import (
23
+ ArtifactRecord,
24
+ CacheProvenance,
25
+ CIRun,
26
+ CIRunManifest,
27
+ CIRunPayload,
28
+ CoverageStatus,
29
+ EntitySnapshot,
30
+ NewsSearchRequest,
31
+ PublicationSearchRequest,
32
+ RefreshRequest,
33
+ RegulatorySearchRequest,
34
+ RegulatorySearchResult,
35
+ RunCoverageSummary,
36
+ SourceName,
37
+ SourceResult,
38
+ TrackedEntity,
39
+ TrialSearchRequest,
40
+ )
41
+
42
+ _RECORDS_FILE = "records.json"
43
+ _MANIFEST_FILE = "manifest.json"
44
+ _MAX_ID_ATTEMPTS = 3
45
+
46
+
47
+ class RunIntegrityError(ValueError):
48
+ """A persisted run failed path, schema, or hash validation."""
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class ProviderBundle:
53
+ trials: Callable[[TrialSearchRequest, datetime], SourceResult]
54
+ regulatory: Callable[[RegulatorySearchRequest, datetime], RegulatorySearchResult]
55
+ news: Callable[[NewsSearchRequest, datetime], SourceResult]
56
+ publications: Callable[[PublicationSearchRequest, datetime], SourceResult]
57
+
58
+
59
+ DEFAULT_PROVIDERS = ProviderBundle(
60
+ trials=lambda request, now: _clinical_trials.search_trials(request, now=now),
61
+ regulatory=lambda request, now: _regulatory.scan_regulatory(request, now=now),
62
+ news=lambda request, now: _web_search.search_news(request, now=now),
63
+ publications=lambda request, now: _pubmed.search_publications(request, now=now),
64
+ )
65
+
66
+
67
+ def create_run(
68
+ request: RefreshRequest,
69
+ *,
70
+ providers: ProviderBundle | None = None,
71
+ now: datetime | None = None,
72
+ ) -> CIRun:
73
+ """Collect every requested source once, then atomically expose the immutable run."""
74
+ effective_now = _utc(now)
75
+ provider_bundle = providers or DEFAULT_PROVIDERS
76
+ snapshots = [_collect_entity(entity, request, provider_bundle, effective_now) for entity in request.entities]
77
+ limitations = [
78
+ f"{snapshot.entity.name}/{source_name}: {limitation}"
79
+ for snapshot in snapshots
80
+ for source_name, result in snapshot.sources.items()
81
+ for limitation in result.limitations
82
+ ]
83
+ return _persist(request, snapshots, limitations, effective_now)
84
+
85
+
86
+ def load_run(run_id: str) -> CIRun:
87
+ """Load a run only after validating its contained paths, schema, size, and hash."""
88
+ try:
89
+ validate_component(run_id, label="run id")
90
+ run_path = contained_path(runs_dir(), run_id)
91
+ manifest_path = contained_path(run_path, _MANIFEST_FILE)
92
+ except (TypeError, ValueError):
93
+ raise RunIntegrityError("invalid run id") from None
94
+
95
+ try:
96
+ manifest = CIRunManifest.model_validate(json.loads(manifest_path.read_text()))
97
+ except (OSError, ValueError, TypeError, json.JSONDecodeError, ValidationError):
98
+ raise RunIntegrityError("run manifest is missing or invalid") from None
99
+ if manifest.run_id != run_id:
100
+ raise RunIntegrityError("manifest run id mismatch")
101
+ if manifest.records.relative_path != _RECORDS_FILE:
102
+ raise RunIntegrityError("manifest records path is invalid")
103
+ if manifest.records.media_type != "application/json":
104
+ raise RunIntegrityError("manifest records media type is invalid")
105
+
106
+ try:
107
+ records_path = contained_path(run_path, manifest.records.relative_path)
108
+ records_bytes = records_path.read_bytes()
109
+ except (OSError, TypeError, ValueError):
110
+ raise RunIntegrityError("run records are missing or invalid") from None
111
+ records_sha256 = hashlib.sha256(records_bytes).hexdigest()
112
+ if records_sha256 != manifest.records.sha256:
113
+ raise RunIntegrityError("records hash mismatch")
114
+ if len(records_bytes) != manifest.records.byte_size:
115
+ raise RunIntegrityError("records size mismatch")
116
+
117
+ try:
118
+ payload = CIRunPayload.model_validate(json.loads(records_bytes))
119
+ except (ValueError, TypeError, json.JSONDecodeError, ValidationError):
120
+ raise RunIntegrityError("run records schema is invalid") from None
121
+ if payload.run_id != run_id:
122
+ raise RunIntegrityError("records run id mismatch")
123
+ return CIRun(
124
+ **payload.model_dump(),
125
+ records_path=str(records_path),
126
+ records_sha256=records_sha256,
127
+ manifest_path=str(manifest_path),
128
+ )
129
+
130
+
131
+ def _collect_entity(
132
+ entity: TrackedEntity,
133
+ request: RefreshRequest,
134
+ providers: ProviderBundle,
135
+ now: datetime,
136
+ ) -> EntitySnapshot:
137
+ sources: dict[str, SourceResult] = {}
138
+ query = _combined_query(entity)
139
+ if "trials" in request.include_sections:
140
+ sources[SourceName.CLINICAL_TRIALS.value] = providers.trials(TrialSearchRequest(query=query), now)
141
+ if "regulatory" in request.include_sections:
142
+ if entity.entity_type == "company":
143
+ sources[SourceName.OPENFDA.value] = _not_applicable(
144
+ SourceName.OPENFDA,
145
+ entity.name,
146
+ "https://api.fda.gov/drug/drugsfda.json",
147
+ now,
148
+ )
149
+ sources[SourceName.DAILYMED.value] = _not_applicable(
150
+ SourceName.DAILYMED,
151
+ entity.name,
152
+ "https://dailymed.nlm.nih.gov/dailymed/services/v2/spls.json",
153
+ now,
154
+ )
155
+ else:
156
+ regulatory = providers.regulatory(
157
+ RegulatorySearchRequest(drug_name=entity.name, aliases=entity.aliases), now
158
+ )
159
+ sources[SourceName.OPENFDA.value] = regulatory.openfda
160
+ if regulatory.dailymed is not None:
161
+ sources[SourceName.DAILYMED.value] = regulatory.dailymed
162
+ if "news" in request.include_sections:
163
+ sources[SourceName.WEB.value] = providers.news(
164
+ NewsSearchRequest(query=query, days_back=request.news_days_back), now
165
+ )
166
+ if "publications" in request.include_sections:
167
+ sources[SourceName.PUBMED.value] = providers.publications(
168
+ PublicationSearchRequest(
169
+ query=query,
170
+ days_back=request.publication_days_back,
171
+ ),
172
+ now,
173
+ )
174
+ return EntitySnapshot(entity=entity, sources=sources)
175
+
176
+
177
+ def _not_applicable(
178
+ source: SourceName,
179
+ query: str,
180
+ source_url: str,
181
+ now: datetime,
182
+ ) -> SourceResult:
183
+ return SourceResult(
184
+ source=source,
185
+ status=CoverageStatus.NOT_APPLICABLE,
186
+ query=query,
187
+ source_url=source_url,
188
+ retrieved_at=now,
189
+ cache=CacheProvenance(status="disabled"),
190
+ limitations=["Regulatory drug records do not apply to a company identity."],
191
+ )
192
+
193
+
194
+ def _combined_query(entity: TrackedEntity) -> str:
195
+ return " OR ".join(json.dumps(name, ensure_ascii=False) for name in [entity.name, *entity.aliases])
196
+
197
+
198
+ def _persist(
199
+ request: RefreshRequest,
200
+ snapshots: list[EntitySnapshot],
201
+ limitations: list[str],
202
+ now: datetime,
203
+ ) -> CIRun:
204
+ root = runs_dir()
205
+ for _attempt in range(_MAX_ID_ATTEMPTS):
206
+ run_id = f"ci_{now.strftime('%Y%m%dT%H%M%SZ')}_{secrets.token_hex(4)}"
207
+ final_path = contained_path(root, run_id)
208
+ if final_path.exists():
209
+ continue
210
+ stage_path = Path(tempfile.mkdtemp(prefix=".ci-stage-", dir=root))
211
+ try:
212
+ payload = CIRunPayload(
213
+ run_id=run_id,
214
+ generated_at=now,
215
+ request=request,
216
+ entities=snapshots,
217
+ limitations=limitations,
218
+ )
219
+ records_path = contained_path(stage_path, _RECORDS_FILE)
220
+ records_text = json.dumps(
221
+ payload.model_dump(mode="json"),
222
+ indent=2,
223
+ ensure_ascii=False,
224
+ )
225
+ atomic_write_text(records_path, records_text)
226
+ records_bytes = records_path.read_bytes()
227
+ records_sha256 = hashlib.sha256(records_bytes).hexdigest()
228
+ manifest = CIRunManifest(
229
+ run_id=run_id,
230
+ generated_at=now,
231
+ records=ArtifactRecord(
232
+ relative_path=_RECORDS_FILE,
233
+ media_type="application/json",
234
+ byte_size=len(records_bytes),
235
+ sha256=records_sha256,
236
+ ),
237
+ coverage=_coverage(snapshots),
238
+ )
239
+ atomic_write_text(
240
+ contained_path(stage_path, _MANIFEST_FILE),
241
+ json.dumps(manifest.model_dump(mode="json"), indent=2, ensure_ascii=False),
242
+ )
243
+ if final_path.exists():
244
+ continue
245
+ os.rename(stage_path, final_path)
246
+ return CIRun(
247
+ **payload.model_dump(),
248
+ records_path=str(contained_path(final_path, _RECORDS_FILE)),
249
+ records_sha256=records_sha256,
250
+ manifest_path=str(contained_path(final_path, _MANIFEST_FILE)),
251
+ )
252
+ except FileExistsError:
253
+ continue
254
+ finally:
255
+ if stage_path.exists():
256
+ shutil.rmtree(stage_path)
257
+ raise RunIntegrityError("could not allocate a unique run id after three attempts")
258
+
259
+
260
+ def _coverage(snapshots: list[EntitySnapshot]) -> list[RunCoverageSummary]:
261
+ return [
262
+ RunCoverageSummary(
263
+ entity=snapshot.entity.name,
264
+ source=result.source,
265
+ status=result.status,
266
+ record_count=len(result.records),
267
+ limitations=result.limitations,
268
+ )
269
+ for snapshot in snapshots
270
+ for result in snapshot.sources.values()
271
+ ]
272
+
273
+
274
+ def _utc(value: datetime | None) -> datetime:
275
+ now = value or datetime.now(timezone.utc)
276
+ if now.tzinfo is None:
277
+ raise ValueError("now must be timezone-aware")
278
+ return now.astimezone(timezone.utc)
@@ -0,0 +1,83 @@
1
+ """Small credential-safe HTTP transport seam for CI providers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import socket
7
+ import urllib.error
8
+ import urllib.request
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Literal, Mapping, Protocol
11
+ from urllib.parse import urlsplit
12
+
13
+ from shared.filesystem import sanitize_url
14
+
15
+ _USER_AGENT = "open-pharma-plugins-competitive-intelligence/1"
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class HttpRequest:
20
+ method: Literal["GET", "POST"]
21
+ url: str = field(repr=False)
22
+ headers: Mapping[str, str] = field(default_factory=dict, repr=False)
23
+ json_body: Mapping[str, Any] | None = field(default=None, repr=False)
24
+ timeout_seconds: float = 30.0
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class HttpResponse:
29
+ status: int
30
+ url: str = field(repr=False)
31
+ body: bytes = field(repr=False)
32
+
33
+
34
+ class HttpTransport(Protocol):
35
+ def request(self, request: HttpRequest) -> HttpResponse: ...
36
+
37
+
38
+ class TransportError(RuntimeError):
39
+ def __init__(self, code: str, message: str) -> None:
40
+ self.code = code
41
+ super().__init__(message)
42
+
43
+
44
+ class UrllibTransport:
45
+ """Production HTTPS adapter that never exposes request credentials in errors."""
46
+
47
+ def request(self, request: HttpRequest) -> HttpResponse:
48
+ _validate_request_url(request.url)
49
+ headers = {"Accept": "application/json", "User-Agent": _USER_AGENT, **request.headers}
50
+ body = None
51
+ if request.json_body is not None:
52
+ body = json.dumps(request.json_body).encode("utf-8")
53
+ headers.setdefault("Content-Type", "application/json")
54
+ wire_request = urllib.request.Request(
55
+ request.url,
56
+ data=body,
57
+ headers=headers,
58
+ method=request.method,
59
+ )
60
+ try:
61
+ with urllib.request.urlopen(wire_request, timeout=request.timeout_seconds) as response:
62
+ response_body = response.read()
63
+ response_url = sanitize_url(response.geturl())
64
+ _validate_request_url(response_url)
65
+ return HttpResponse(
66
+ status=int(getattr(response, "status", response.getcode())),
67
+ url=response_url,
68
+ body=response_body,
69
+ )
70
+ except urllib.error.HTTPError:
71
+ raise TransportError("http_error", "provider returned an HTTP error") from None
72
+ except (TimeoutError, socket.timeout):
73
+ raise TransportError("timeout", "provider request timed out") from None
74
+ except (urllib.error.URLError, OSError):
75
+ raise TransportError("network_error", "provider request failed") from None
76
+ except (TypeError, ValueError):
77
+ raise TransportError("invalid_response", "provider returned an invalid response") from None
78
+
79
+
80
+ def _validate_request_url(url: str) -> None:
81
+ parsed = urlsplit(url)
82
+ if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password:
83
+ raise TransportError("network_error", "provider request URL was rejected")
@@ -0,0 +1,113 @@
1
+ """Validated persistent watchlist for tracked competitor entities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from pydantic import ValidationError
11
+
12
+ from shared.filesystem import atomic_write_json, contained_path, ensure_private_dir
13
+
14
+ from .models import TrackedEntity
15
+
16
+
17
+ class WatchlistError(ValueError):
18
+ """The persisted watchlist cannot be trusted or validated."""
19
+
20
+
21
+ def _data_dir() -> Path:
22
+ from shared.env import get_env
23
+
24
+ return ensure_private_dir(
25
+ get_env(
26
+ "OPEN_PHARMA_CI_DATA_DIR",
27
+ str(Path.home() / ".open-pharma-plugins" / "competitive-intelligence"),
28
+ )
29
+ )
30
+
31
+
32
+ def _watchlist_path() -> Path:
33
+ return contained_path(_data_dir(), "watchlist.json")
34
+
35
+
36
+ def reports_dir() -> Path:
37
+ return ensure_private_dir(contained_path(_data_dir(), "reports"))
38
+
39
+
40
+ def runs_dir() -> Path:
41
+ return ensure_private_dir(contained_path(_data_dir(), "runs"))
42
+
43
+
44
+ def load_tracked_entities() -> list[TrackedEntity]:
45
+ path = _watchlist_path()
46
+ if not path.exists():
47
+ return []
48
+ try:
49
+ payload = json.loads(path.read_text())
50
+ if not isinstance(payload, dict) or not isinstance(payload.get("entities"), list):
51
+ raise ValueError("watchlist root must contain an entities list")
52
+ return [TrackedEntity.model_validate(entity) for entity in payload["entities"]]
53
+ except (OSError, ValueError, TypeError, json.JSONDecodeError, ValidationError):
54
+ raise WatchlistError("watchlist.json is malformed or contains an invalid entity") from None
55
+
56
+
57
+ def load_watchlist() -> list[dict[str, Any]]:
58
+ return [entity.model_dump(mode="json", exclude_none=True) for entity in load_tracked_entities()]
59
+
60
+
61
+ def save_watchlist(entities: list[dict[str, Any] | TrackedEntity]) -> None:
62
+ try:
63
+ validated = [TrackedEntity.model_validate(entity) for entity in entities]
64
+ except (TypeError, ValueError, ValidationError):
65
+ raise WatchlistError("refusing to persist an invalid watchlist") from None
66
+ atomic_write_json(
67
+ _watchlist_path(),
68
+ {"entities": [entity.model_dump(mode="json", exclude_none=True) for entity in validated]},
69
+ )
70
+
71
+
72
+ def add_entity(
73
+ entity_type: str,
74
+ name: str,
75
+ therapeutic_area: str = "",
76
+ aliases: list[str] | None = None,
77
+ ) -> list[dict[str, Any]]:
78
+ entities = load_tracked_entities()
79
+ for index, entity in enumerate(entities):
80
+ if entity.name.casefold() == name.strip().casefold() and entity.entity_type == entity_type:
81
+ entities[index] = TrackedEntity(
82
+ entity_type=entity.entity_type,
83
+ name=entity.name,
84
+ therapeutic_area=therapeutic_area or entity.therapeutic_area,
85
+ aliases=[*entity.aliases, *(aliases or [])],
86
+ added_at=entity.added_at,
87
+ )
88
+ save_watchlist(entities)
89
+ return [item.model_dump(mode="json", exclude_none=True) for item in entities]
90
+
91
+ entities.append(
92
+ TrackedEntity(
93
+ entity_type=entity_type,
94
+ name=name,
95
+ therapeutic_area=therapeutic_area,
96
+ aliases=aliases or [],
97
+ added_at=datetime.now(timezone.utc),
98
+ )
99
+ )
100
+ save_watchlist(entities)
101
+ return [item.model_dump(mode="json", exclude_none=True) for item in entities]
102
+
103
+
104
+ def remove_entity(name: str) -> list[dict[str, Any]]:
105
+ entities = [entity for entity in load_tracked_entities() if entity.name.casefold() != name.strip().casefold()]
106
+ save_watchlist(entities)
107
+ return [item.model_dump(mode="json", exclude_none=True) for item in entities]
108
+
109
+
110
+ def get_search_names(entity: dict[str, Any] | TrackedEntity) -> list[str]:
111
+ """Return the validated primary name and case-insensitively unique aliases."""
112
+ tracked = TrackedEntity.model_validate(entity)
113
+ return [tracked.name, *tracked.aliases]