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