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