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,525 @@
|
|
|
1
|
+
"""Pydantic domain models for competitive intelligence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import date as Date
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
from urllib.parse import urlsplit
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
13
|
+
|
|
14
|
+
from shared.filesystem import sanitize_url
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CoverageStatus(str, Enum):
|
|
18
|
+
COMPLETE = "complete"
|
|
19
|
+
PARTIAL = "partial"
|
|
20
|
+
FAILED = "failed"
|
|
21
|
+
NOT_CONFIGURED = "not_configured"
|
|
22
|
+
NOT_APPLICABLE = "not_applicable"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CacheStatus(str, Enum):
|
|
26
|
+
HIT = "hit"
|
|
27
|
+
MISS = "miss"
|
|
28
|
+
DISABLED = "disabled"
|
|
29
|
+
MIXED = "mixed"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SourceName(str, Enum):
|
|
33
|
+
CLINICAL_TRIALS = "clinicaltrials_gov"
|
|
34
|
+
OPENFDA = "openfda"
|
|
35
|
+
DAILYMED = "dailymed"
|
|
36
|
+
PUBMED = "pubmed"
|
|
37
|
+
WEB = "web_search"
|
|
38
|
+
DOCUMENT = "document"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class DatePrecision(str, Enum):
|
|
42
|
+
DAY = "day"
|
|
43
|
+
MONTH = "month"
|
|
44
|
+
YEAR = "year"
|
|
45
|
+
UNKNOWN = "unknown"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CacheProvenance(BaseModel):
|
|
49
|
+
status: CacheStatus
|
|
50
|
+
cached_at: datetime | None = None
|
|
51
|
+
schema_version: int = 2
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SourceError(BaseModel):
|
|
55
|
+
code: str
|
|
56
|
+
message: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SourceRequestEvidence(BaseModel):
|
|
60
|
+
query: str
|
|
61
|
+
source_url: str
|
|
62
|
+
retrieved_at: datetime
|
|
63
|
+
cache: CacheProvenance
|
|
64
|
+
status: CoverageStatus
|
|
65
|
+
record_count: int = Field(ge=0)
|
|
66
|
+
error: SourceError | None = None
|
|
67
|
+
|
|
68
|
+
@model_validator(mode="after")
|
|
69
|
+
def validate_evidence(self) -> "SourceRequestEvidence":
|
|
70
|
+
_validate_https_url(self.source_url)
|
|
71
|
+
if self.status in {CoverageStatus.FAILED, CoverageStatus.NOT_CONFIGURED} and self.error is None:
|
|
72
|
+
raise ValueError("failed and not_configured request evidence requires an error")
|
|
73
|
+
if self.status in {CoverageStatus.COMPLETE, CoverageStatus.NOT_APPLICABLE} and self.error is not None:
|
|
74
|
+
raise ValueError("complete and not_applicable request evidence cannot contain an error")
|
|
75
|
+
if (
|
|
76
|
+
self.status
|
|
77
|
+
in {
|
|
78
|
+
CoverageStatus.FAILED,
|
|
79
|
+
CoverageStatus.NOT_CONFIGURED,
|
|
80
|
+
CoverageStatus.NOT_APPLICABLE,
|
|
81
|
+
}
|
|
82
|
+
and self.record_count
|
|
83
|
+
):
|
|
84
|
+
raise ValueError(f"{self.status.value} request evidence cannot contain records")
|
|
85
|
+
if self.status == CoverageStatus.PARTIAL and self.record_count == 0:
|
|
86
|
+
raise ValueError("partial request evidence requires at least one usable record")
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class SourceResult(BaseModel):
|
|
91
|
+
source: SourceName
|
|
92
|
+
provider: str | None = None
|
|
93
|
+
status: CoverageStatus
|
|
94
|
+
query: str
|
|
95
|
+
source_url: str
|
|
96
|
+
retrieved_at: datetime
|
|
97
|
+
cache: CacheProvenance
|
|
98
|
+
records: list[dict[str, Any]] = Field(default_factory=list)
|
|
99
|
+
total_available: int | None = Field(default=None, ge=0)
|
|
100
|
+
requests: list[SourceRequestEvidence] = Field(default_factory=list)
|
|
101
|
+
limitations: list[str] = Field(default_factory=list)
|
|
102
|
+
error: SourceError | None = None
|
|
103
|
+
|
|
104
|
+
@model_validator(mode="after")
|
|
105
|
+
def validate_evidence(self) -> "SourceResult":
|
|
106
|
+
if self.source == SourceName.DOCUMENT:
|
|
107
|
+
if not re.fullmatch(r"urn:sha256:[0-9a-f]{64}", self.source_url):
|
|
108
|
+
raise ValueError("document evidence requires a lowercase SHA-256 URN")
|
|
109
|
+
else:
|
|
110
|
+
_validate_https_url(self.source_url)
|
|
111
|
+
|
|
112
|
+
if self.status in {CoverageStatus.FAILED, CoverageStatus.NOT_CONFIGURED} and self.error is None:
|
|
113
|
+
raise ValueError("failed and not_configured source results requires an error")
|
|
114
|
+
if self.status in {CoverageStatus.COMPLETE, CoverageStatus.NOT_APPLICABLE} and self.error is not None:
|
|
115
|
+
raise ValueError("complete and not_applicable source results cannot contain an error")
|
|
116
|
+
non_usable = {
|
|
117
|
+
CoverageStatus.FAILED,
|
|
118
|
+
CoverageStatus.NOT_CONFIGURED,
|
|
119
|
+
CoverageStatus.NOT_APPLICABLE,
|
|
120
|
+
}
|
|
121
|
+
if self.status in non_usable and self.records:
|
|
122
|
+
raise ValueError(f"{self.status.value} source results cannot contain records")
|
|
123
|
+
if self.status in non_usable and self.total_available is not None:
|
|
124
|
+
raise ValueError(f"{self.status.value} source results cannot contain a total")
|
|
125
|
+
if self.total_available is not None and self.total_available < len(self.records):
|
|
126
|
+
raise ValueError("total_available cannot be smaller than the returned record count")
|
|
127
|
+
|
|
128
|
+
allowed_non_usable_requests = {
|
|
129
|
+
CoverageStatus.FAILED: {CoverageStatus.FAILED, CoverageStatus.NOT_CONFIGURED},
|
|
130
|
+
CoverageStatus.NOT_CONFIGURED: {CoverageStatus.NOT_CONFIGURED},
|
|
131
|
+
CoverageStatus.NOT_APPLICABLE: {CoverageStatus.NOT_APPLICABLE},
|
|
132
|
+
}
|
|
133
|
+
if self.status in allowed_non_usable_requests and any(
|
|
134
|
+
request.status not in allowed_non_usable_requests[self.status] for request in self.requests
|
|
135
|
+
):
|
|
136
|
+
raise ValueError(f"{self.status.value} source results cannot contain usable requests")
|
|
137
|
+
if self.status == CoverageStatus.COMPLETE and any(
|
|
138
|
+
request.status != CoverageStatus.COMPLETE for request in self.requests
|
|
139
|
+
):
|
|
140
|
+
raise ValueError("complete source results require complete constituent requests")
|
|
141
|
+
if self.status == CoverageStatus.PARTIAL:
|
|
142
|
+
has_success = any(
|
|
143
|
+
request.status == CoverageStatus.COMPLETE
|
|
144
|
+
or (request.status == CoverageStatus.PARTIAL and request.record_count > 0)
|
|
145
|
+
for request in self.requests
|
|
146
|
+
)
|
|
147
|
+
has_failure_or_truncation = any(
|
|
148
|
+
request.status in {CoverageStatus.PARTIAL, CoverageStatus.FAILED} for request in self.requests
|
|
149
|
+
) or (self.total_available is not None and self.total_available > len(self.records))
|
|
150
|
+
if not has_success or not has_failure_or_truncation:
|
|
151
|
+
raise ValueError("partial source results require successful and failed or truncated requests")
|
|
152
|
+
return self
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def aggregate_coverage(statuses: list[CoverageStatus]) -> CoverageStatus:
|
|
156
|
+
"""Aggregate source coverage without turning missing coverage into zero results."""
|
|
157
|
+
applicable = [status for status in statuses if status != CoverageStatus.NOT_APPLICABLE]
|
|
158
|
+
if not applicable:
|
|
159
|
+
return CoverageStatus.NOT_APPLICABLE
|
|
160
|
+
if all(status == CoverageStatus.COMPLETE for status in applicable):
|
|
161
|
+
return CoverageStatus.COMPLETE
|
|
162
|
+
if any(status == CoverageStatus.PARTIAL for status in applicable):
|
|
163
|
+
return CoverageStatus.PARTIAL
|
|
164
|
+
if CoverageStatus.COMPLETE in applicable:
|
|
165
|
+
return CoverageStatus.PARTIAL
|
|
166
|
+
if all(status == CoverageStatus.NOT_CONFIGURED for status in applicable):
|
|
167
|
+
return CoverageStatus.NOT_CONFIGURED
|
|
168
|
+
if CoverageStatus.FAILED in applicable:
|
|
169
|
+
return CoverageStatus.FAILED
|
|
170
|
+
return CoverageStatus.NOT_CONFIGURED
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def aggregate_cache_status(statuses: list[CacheStatus]) -> CacheStatus:
|
|
174
|
+
"""Return a single cache state while retaining mixed provenance explicitly."""
|
|
175
|
+
if not statuses:
|
|
176
|
+
return CacheStatus.DISABLED
|
|
177
|
+
first = statuses[0]
|
|
178
|
+
return first if all(status == first for status in statuses) else CacheStatus.MIXED
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _validate_https_url(value: str) -> None:
|
|
182
|
+
parsed = urlsplit(value)
|
|
183
|
+
if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password:
|
|
184
|
+
raise ValueError("source URL must be credential-free HTTPS")
|
|
185
|
+
if sanitize_url(value) != value:
|
|
186
|
+
raise ValueError("source URL must not contain credential query parameters")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class TrialSearchRequest(BaseModel):
|
|
190
|
+
query: str = Field(min_length=1)
|
|
191
|
+
phase: Literal["PHASE1", "PHASE2", "PHASE3", "PHASE4"] | None = None
|
|
192
|
+
status: str | None = None
|
|
193
|
+
max_results: int = Field(default=20, ge=1, le=50)
|
|
194
|
+
|
|
195
|
+
@field_validator("phase", mode="before")
|
|
196
|
+
@classmethod
|
|
197
|
+
def normalize_phase(cls, value: str | None) -> str | None:
|
|
198
|
+
return {"1": "PHASE1", "2": "PHASE2", "3": "PHASE3", "4": "PHASE4"}.get(value, value)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class TrialDetailRequest(BaseModel):
|
|
202
|
+
nct_id: str = Field(pattern=r"^NCT\d{8}$")
|
|
203
|
+
|
|
204
|
+
@field_validator("nct_id", mode="before")
|
|
205
|
+
@classmethod
|
|
206
|
+
def normalize_nct_id(cls, value: str) -> str:
|
|
207
|
+
return value.strip().upper()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class TrialArm(BaseModel):
|
|
211
|
+
label: str
|
|
212
|
+
type: str
|
|
213
|
+
description: str = ""
|
|
214
|
+
interventions: list[str] = Field(default_factory=list)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class TrialIntervention(BaseModel):
|
|
218
|
+
name: str = Field(min_length=1)
|
|
219
|
+
intervention_type: str = Field(min_length=1)
|
|
220
|
+
description: str = ""
|
|
221
|
+
other_names: list[str] = Field(default_factory=list)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class TrialResultsSummary(BaseModel):
|
|
225
|
+
has_results: bool
|
|
226
|
+
primary_outcomes_count: int = 0
|
|
227
|
+
adverse_events_reported: bool = False
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class Trial(BaseModel):
|
|
231
|
+
nct_id: str
|
|
232
|
+
title: str
|
|
233
|
+
sponsor: str
|
|
234
|
+
collaborators: list[str] = Field(default_factory=list)
|
|
235
|
+
phase: str
|
|
236
|
+
status: str
|
|
237
|
+
conditions: list[str] = Field(default_factory=list)
|
|
238
|
+
interventions: list[TrialIntervention] = Field(default_factory=list)
|
|
239
|
+
mechanism: str | None = None
|
|
240
|
+
enrollment: int | None = None
|
|
241
|
+
start_date: str | None = None
|
|
242
|
+
primary_completion_date: str | None = None
|
|
243
|
+
estimated_completion_date: str | None = None
|
|
244
|
+
study_type: str = "INTERVENTIONAL"
|
|
245
|
+
primary_endpoints: list[str] = Field(default_factory=list)
|
|
246
|
+
has_results: bool = False
|
|
247
|
+
source_url: str
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class TrialDetail(BaseModel):
|
|
251
|
+
trial: Trial
|
|
252
|
+
arms: list[TrialArm] = Field(default_factory=list)
|
|
253
|
+
secondary_endpoints: list[str] = Field(default_factory=list)
|
|
254
|
+
eligibility_criteria: str | None = None
|
|
255
|
+
status_history: list[dict[str, str]] = Field(default_factory=list)
|
|
256
|
+
milestone_dates: list[dict[str, str]] = Field(default_factory=list)
|
|
257
|
+
results_summary: TrialResultsSummary | None = None
|
|
258
|
+
publications: list[str] = Field(default_factory=list)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class RegulatorySearchRequest(BaseModel):
|
|
262
|
+
drug_name: str = Field(min_length=1)
|
|
263
|
+
aliases: list[str] = Field(default_factory=list)
|
|
264
|
+
date_from: Date | None = None
|
|
265
|
+
date_to: Date | None = None
|
|
266
|
+
include_label_history: bool = True
|
|
267
|
+
max_results: int = Field(default=20, ge=1, le=50)
|
|
268
|
+
|
|
269
|
+
@model_validator(mode="after")
|
|
270
|
+
def validate_dates(self) -> "RegulatorySearchRequest":
|
|
271
|
+
if self.date_from and self.date_to and self.date_from > self.date_to:
|
|
272
|
+
raise ValueError("date_from must be on or before date_to")
|
|
273
|
+
return self
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class RegulatoryEvent(BaseModel):
|
|
277
|
+
date: Date | None = None
|
|
278
|
+
event_type: Literal["approval", "supplement", "label_change", "other"]
|
|
279
|
+
application_number: str = ""
|
|
280
|
+
submission: str = ""
|
|
281
|
+
status: str = ""
|
|
282
|
+
brand_name: str = ""
|
|
283
|
+
generic_name: str = ""
|
|
284
|
+
sponsor: str = ""
|
|
285
|
+
manufacturer_names: list[str] = Field(default_factory=list)
|
|
286
|
+
description: str = ""
|
|
287
|
+
source_url: str
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class LabelHistoryEntry(BaseModel):
|
|
291
|
+
set_id: str
|
|
292
|
+
spl_version: str
|
|
293
|
+
published_date: str
|
|
294
|
+
title: str
|
|
295
|
+
source_url: str
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
class RegulatorySearchResult(BaseModel):
|
|
299
|
+
drug_name: str
|
|
300
|
+
coverage: CoverageStatus
|
|
301
|
+
openfda: SourceResult
|
|
302
|
+
dailymed: SourceResult | None = None
|
|
303
|
+
events: list[RegulatoryEvent] = Field(default_factory=list)
|
|
304
|
+
label_history: list[LabelHistoryEntry] = Field(default_factory=list)
|
|
305
|
+
limitations: list[str] = Field(default_factory=list)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class PublicationSearchRequest(BaseModel):
|
|
309
|
+
query: str = Field(min_length=1)
|
|
310
|
+
days_back: int = Field(default=365, ge=1, le=1825)
|
|
311
|
+
max_results: int = Field(default=10, ge=1, le=30)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class Publication(BaseModel):
|
|
315
|
+
pmid: str
|
|
316
|
+
title: str
|
|
317
|
+
authors: list[str] = Field(default_factory=list)
|
|
318
|
+
journal: str = ""
|
|
319
|
+
pub_date: str = ""
|
|
320
|
+
abstract_excerpt: str = ""
|
|
321
|
+
pub_types: list[str] = Field(default_factory=list)
|
|
322
|
+
source_url: str
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class SearchBackend(str, Enum):
|
|
326
|
+
AUTO = "auto"
|
|
327
|
+
SERPER = "serper"
|
|
328
|
+
TAVILY = "tavily"
|
|
329
|
+
EXA = "exa"
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class NewsSearchRequest(BaseModel):
|
|
333
|
+
query: str = Field(min_length=1)
|
|
334
|
+
days_back: int = Field(default=90, ge=1, le=365)
|
|
335
|
+
max_results: int = Field(default=10, ge=1, le=30)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
class NewsItem(BaseModel):
|
|
339
|
+
title: str
|
|
340
|
+
url: str
|
|
341
|
+
snippet: str = ""
|
|
342
|
+
source: str = ""
|
|
343
|
+
published_date: str | None = None
|
|
344
|
+
|
|
345
|
+
@field_validator("url")
|
|
346
|
+
@classmethod
|
|
347
|
+
def validate_outbound_url(cls, value: str) -> str:
|
|
348
|
+
parsed = urlsplit(value)
|
|
349
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.username or parsed.password:
|
|
350
|
+
raise ValueError("news URL must be credential-free HTTP or HTTPS")
|
|
351
|
+
if sanitize_url(value) != value:
|
|
352
|
+
raise ValueError("news URL must not contain credential query parameters")
|
|
353
|
+
return value
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
SectionName = Literal["trials", "regulatory", "news", "publications"]
|
|
357
|
+
_SECTION_ORDER = ("trials", "regulatory", "news", "publications")
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
class TrackedEntity(BaseModel):
|
|
361
|
+
entity_type: Literal["drug", "company"]
|
|
362
|
+
name: str = Field(min_length=1)
|
|
363
|
+
therapeutic_area: str = ""
|
|
364
|
+
aliases: list[str] = Field(default_factory=list)
|
|
365
|
+
added_at: datetime | None = None
|
|
366
|
+
|
|
367
|
+
@field_validator("name", "therapeutic_area", mode="before")
|
|
368
|
+
@classmethod
|
|
369
|
+
def strip_text(cls, value: str) -> str:
|
|
370
|
+
return value.strip() if isinstance(value, str) else value
|
|
371
|
+
|
|
372
|
+
@model_validator(mode="after")
|
|
373
|
+
def normalize_aliases(self) -> "TrackedEntity":
|
|
374
|
+
seen = {self.name.casefold()}
|
|
375
|
+
aliases = []
|
|
376
|
+
for raw in self.aliases:
|
|
377
|
+
alias = raw.strip()
|
|
378
|
+
if alias and alias.casefold() not in seen:
|
|
379
|
+
seen.add(alias.casefold())
|
|
380
|
+
aliases.append(alias)
|
|
381
|
+
self.aliases = aliases
|
|
382
|
+
if self.added_at is not None and self.added_at.tzinfo is None:
|
|
383
|
+
raise ValueError("added_at must be timezone-aware")
|
|
384
|
+
return self
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
class RefreshRequest(BaseModel):
|
|
388
|
+
entities: list[TrackedEntity] = Field(min_length=1)
|
|
389
|
+
include_sections: list[SectionName] = Field(default_factory=lambda: list(_SECTION_ORDER))
|
|
390
|
+
news_days_back: int = Field(default=90, ge=1, le=365)
|
|
391
|
+
publication_days_back: int = Field(default=365, ge=1, le=1825)
|
|
392
|
+
|
|
393
|
+
@field_validator("include_sections", mode="before")
|
|
394
|
+
@classmethod
|
|
395
|
+
def normalize_sections(cls, value: Any) -> list[Any]:
|
|
396
|
+
values = list(value)
|
|
397
|
+
unique = list(dict.fromkeys(values))
|
|
398
|
+
return [section for section in _SECTION_ORDER if section in unique] + [
|
|
399
|
+
section for section in unique if section not in _SECTION_ORDER
|
|
400
|
+
]
|
|
401
|
+
|
|
402
|
+
@field_validator("include_sections")
|
|
403
|
+
@classmethod
|
|
404
|
+
def require_section(cls, value: list[SectionName]) -> list[SectionName]:
|
|
405
|
+
if not value:
|
|
406
|
+
raise ValueError("include_sections requires at least one section")
|
|
407
|
+
return value
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
class EntitySnapshot(BaseModel):
|
|
411
|
+
entity: TrackedEntity
|
|
412
|
+
sources: dict[str, SourceResult]
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
class ArtifactRecord(BaseModel):
|
|
416
|
+
relative_path: str
|
|
417
|
+
media_type: str
|
|
418
|
+
byte_size: int = Field(ge=0)
|
|
419
|
+
sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
class RunCoverageSummary(BaseModel):
|
|
423
|
+
entity: str
|
|
424
|
+
source: SourceName
|
|
425
|
+
status: CoverageStatus
|
|
426
|
+
record_count: int = Field(ge=0)
|
|
427
|
+
limitations: list[str] = Field(default_factory=list)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
class CIRunPayload(BaseModel):
|
|
431
|
+
schema_version: Literal[1] = 1
|
|
432
|
+
run_id: str
|
|
433
|
+
generated_at: datetime
|
|
434
|
+
request: RefreshRequest
|
|
435
|
+
entities: list[EntitySnapshot]
|
|
436
|
+
limitations: list[str] = Field(default_factory=list)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
class CIRunManifest(BaseModel):
|
|
440
|
+
schema_version: Literal[1] = 1
|
|
441
|
+
run_id: str
|
|
442
|
+
generated_at: datetime
|
|
443
|
+
records: ArtifactRecord
|
|
444
|
+
coverage: list[RunCoverageSummary] = Field(default_factory=list)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
class CIRun(CIRunPayload):
|
|
448
|
+
records_path: str
|
|
449
|
+
records_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
450
|
+
manifest_path: str
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
class ArtifactManifest(BaseModel):
|
|
454
|
+
schema_version: Literal[1] = 1
|
|
455
|
+
artifact_id: str
|
|
456
|
+
run_id: str
|
|
457
|
+
run_records_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
458
|
+
generated_at: datetime
|
|
459
|
+
display_stem: str
|
|
460
|
+
artifacts: list[ArtifactRecord]
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
class ReportArtifactResult(BaseModel):
|
|
464
|
+
output_dir: str
|
|
465
|
+
json_path: str
|
|
466
|
+
html_path: str
|
|
467
|
+
csv_files: list[str] = Field(default_factory=list)
|
|
468
|
+
manifest_path: str
|
|
469
|
+
manifest: ArtifactManifest
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
class TimelineArtifactResult(BaseModel):
|
|
473
|
+
output_dir: str
|
|
474
|
+
html_path: str
|
|
475
|
+
manifest_path: str
|
|
476
|
+
manifest: ArtifactManifest
|
|
477
|
+
included_events: int = Field(ge=0)
|
|
478
|
+
excluded_old_events: int = Field(ge=0)
|
|
479
|
+
excluded_undated_events: int = Field(ge=0)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
class LabelEvent(BaseModel):
|
|
483
|
+
drug_name: str
|
|
484
|
+
generic_name: str | None = None
|
|
485
|
+
application_number: str | None = None
|
|
486
|
+
event_type: str
|
|
487
|
+
date: str
|
|
488
|
+
description: str
|
|
489
|
+
indication: str | None = None
|
|
490
|
+
sections_changed: list[str] = Field(default_factory=list)
|
|
491
|
+
sponsor: str | None = None
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
class CIEvent(BaseModel):
|
|
495
|
+
event_type: str
|
|
496
|
+
date: str | None = None
|
|
497
|
+
competitor: str
|
|
498
|
+
product: str | None = None
|
|
499
|
+
therapeutic_area: str | None = None
|
|
500
|
+
description: str
|
|
501
|
+
implication: str | None = None
|
|
502
|
+
source: str | None = None
|
|
503
|
+
source_page: int | None = None
|
|
504
|
+
confidence: str = "medium"
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
class LandscapeEntry(BaseModel):
|
|
508
|
+
competitor: str
|
|
509
|
+
product: str
|
|
510
|
+
mechanism: str | None = None
|
|
511
|
+
phase: str
|
|
512
|
+
status: str
|
|
513
|
+
indications: list[str] = Field(default_factory=list)
|
|
514
|
+
key_trials: list[str] = Field(default_factory=list)
|
|
515
|
+
expected_milestones: list[dict[str, str]] = Field(default_factory=list)
|
|
516
|
+
differentiators: list[str] = Field(default_factory=list)
|
|
517
|
+
approval_date: str | None = None
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
class Landscape(BaseModel):
|
|
521
|
+
therapeutic_area: str
|
|
522
|
+
generated_at: str
|
|
523
|
+
entries: list[LandscapeEntry] = Field(default_factory=list)
|
|
524
|
+
summary: str = ""
|
|
525
|
+
data_sources: list[str] = Field(default_factory=list)
|
|
File without changes
|