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,247 @@
|
|
|
1
|
+
"""ci_extract_events — extract CI events from user-provided documents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ExtractEventsArgs(BaseModel):
|
|
12
|
+
file_path: str = Field(description="Path to document: PDF, DOCX, TXT, CSV, or MD")
|
|
13
|
+
focus: list[str] | None = Field(
|
|
14
|
+
default=None,
|
|
15
|
+
description="Focus areas: trial_readout, regulatory, label_change, pricing, market_access, partnership",
|
|
16
|
+
)
|
|
17
|
+
therapeutic_area: str | None = Field(
|
|
18
|
+
default=None,
|
|
19
|
+
description="Therapeutic area context for relevance filtering",
|
|
20
|
+
)
|
|
21
|
+
max_pages: int = Field(default=50, ge=1, le=200, description="Max pages to process (PDF only)")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
TOOL: dict[str, Any] = {
|
|
25
|
+
"name": "ci_extract_events",
|
|
26
|
+
"description": (
|
|
27
|
+
"Extract structured competitive intelligence events from a document "
|
|
28
|
+
"(PDF, DOCX, TXT, CSV, Markdown). Uses heuristic pattern matching "
|
|
29
|
+
"to identify dates, company names, drug names, and event types. "
|
|
30
|
+
"Returns events in the common CIEvent schema."
|
|
31
|
+
),
|
|
32
|
+
"args": ExtractEventsArgs,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_PHARMA_COMPANIES = {
|
|
36
|
+
"pfizer",
|
|
37
|
+
"merck",
|
|
38
|
+
"roche",
|
|
39
|
+
"novartis",
|
|
40
|
+
"johnson & johnson",
|
|
41
|
+
"j&j",
|
|
42
|
+
"astrazeneca",
|
|
43
|
+
"bristol-myers squibb",
|
|
44
|
+
"bms",
|
|
45
|
+
"eli lilly",
|
|
46
|
+
"lilly",
|
|
47
|
+
"abbvie",
|
|
48
|
+
"amgen",
|
|
49
|
+
"gilead",
|
|
50
|
+
"sanofi",
|
|
51
|
+
"gsk",
|
|
52
|
+
"glaxosmithkline",
|
|
53
|
+
"bayer",
|
|
54
|
+
"takeda",
|
|
55
|
+
"regeneron",
|
|
56
|
+
"biogen",
|
|
57
|
+
"moderna",
|
|
58
|
+
"vertex",
|
|
59
|
+
"seagen",
|
|
60
|
+
"incyte",
|
|
61
|
+
"jazz",
|
|
62
|
+
"daiichi sankyo",
|
|
63
|
+
"astellas",
|
|
64
|
+
"boehringer ingelheim",
|
|
65
|
+
"merck kgaa",
|
|
66
|
+
"shire",
|
|
67
|
+
"alexion",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
_EVENT_PATTERNS = {
|
|
71
|
+
"approval": re.compile(r"(?:FDA|EMA|NMPA|PMDA|TGA)\s+(?:approved|grants?\s+approval|clearance)", re.IGNORECASE),
|
|
72
|
+
"trial_readout": re.compile(
|
|
73
|
+
r"(?:phase\s+[I1-4]+|pivotal)\s+(?:trial|study)\s+(?:met|missed|results?|data|readout)", re.IGNORECASE
|
|
74
|
+
),
|
|
75
|
+
"regulatory_submission": re.compile(
|
|
76
|
+
r"(?:filed|submitted|submission|sNDA|sBLA|MAA|NDA|BLA)\s+(?:to|with|for)?\s*(?:FDA|EMA)?", re.IGNORECASE
|
|
77
|
+
),
|
|
78
|
+
"label_change": re.compile(
|
|
79
|
+
r"label\s+(?:change|update|revision|expansion)|new\s+indication|supplemental\s+approval", re.IGNORECASE
|
|
80
|
+
),
|
|
81
|
+
"partnership": re.compile(
|
|
82
|
+
r"(?:partnership|collaboration|licensing|co-development|acquisition|merger|deal)\s+(?:with|between|agreement)?",
|
|
83
|
+
re.IGNORECASE,
|
|
84
|
+
),
|
|
85
|
+
"pricing": re.compile(
|
|
86
|
+
r"(?:list\s+price|WAC|launch\s+price|pricing\s+(?:strategy|decision|announcement)|reimbursement\s+(?:decision|approval))",
|
|
87
|
+
re.IGNORECASE,
|
|
88
|
+
),
|
|
89
|
+
"discontinuation": re.compile(
|
|
90
|
+
r"(?:discontinue|terminate|halt|suspend|withdraw)\w*\s+(?:trial|study|program|development)", re.IGNORECASE
|
|
91
|
+
),
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
_DATE_PATTERN = re.compile(
|
|
95
|
+
r"(?:(?:January|February|March|April|May|June|July|August|September|October|November|December)"
|
|
96
|
+
r"\s+\d{1,2},?\s+\d{4})"
|
|
97
|
+
r"|(?:\d{1,2}\s+(?:January|February|March|April|May|June|July|August|September|October|November|December)"
|
|
98
|
+
r"\s+\d{4})"
|
|
99
|
+
r"|(?:\d{4}-\d{2}-\d{2})"
|
|
100
|
+
r"|(?:\d{1,2}/\d{1,2}/\d{4})",
|
|
101
|
+
re.IGNORECASE,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def handle(arguments: dict[str, Any]) -> list[dict[str, Any]]:
|
|
106
|
+
import json
|
|
107
|
+
import os
|
|
108
|
+
|
|
109
|
+
file_path = arguments["file_path"]
|
|
110
|
+
focus = arguments.get("focus")
|
|
111
|
+
therapeutic_area = arguments.get("therapeutic_area")
|
|
112
|
+
max_pages = arguments.get("max_pages", 50)
|
|
113
|
+
|
|
114
|
+
if not os.path.isfile(file_path):
|
|
115
|
+
return [{"type": "text", "text": json.dumps({"error": f"File not found: {file_path}"})}]
|
|
116
|
+
|
|
117
|
+
ext = os.path.splitext(file_path)[1].lower()
|
|
118
|
+
pages = _extract_text(file_path, ext, max_pages)
|
|
119
|
+
|
|
120
|
+
events = []
|
|
121
|
+
for page in pages:
|
|
122
|
+
page_events = _extract_events_from_text(page["text"], page["page_number"], os.path.basename(file_path), focus)
|
|
123
|
+
events.extend(page_events)
|
|
124
|
+
|
|
125
|
+
if therapeutic_area:
|
|
126
|
+
for e in events:
|
|
127
|
+
e["therapeutic_area"] = therapeutic_area
|
|
128
|
+
|
|
129
|
+
events.sort(key=lambda e: e.get("date") or "", reverse=True)
|
|
130
|
+
|
|
131
|
+
output = {
|
|
132
|
+
"file": os.path.basename(file_path),
|
|
133
|
+
"pages_processed": len(pages),
|
|
134
|
+
"total_events": len(events),
|
|
135
|
+
"therapeutic_area": therapeutic_area,
|
|
136
|
+
"events": events,
|
|
137
|
+
}
|
|
138
|
+
return [{"type": "text", "text": json.dumps(output, indent=2)}]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _extract_text(file_path: str, ext: str, max_pages: int) -> list[dict[str, Any]]:
|
|
142
|
+
if ext == ".pdf":
|
|
143
|
+
return _extract_pdf(file_path, max_pages)
|
|
144
|
+
if ext in (".docx", ".doc"):
|
|
145
|
+
return _extract_docx(file_path)
|
|
146
|
+
return _extract_plain(file_path)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _extract_pdf(file_path: str, max_pages: int) -> list[dict[str, Any]]:
|
|
150
|
+
try:
|
|
151
|
+
import pypdfium2 as pdfium
|
|
152
|
+
except ImportError:
|
|
153
|
+
return [{"page_number": 1, "text": _read_file_text(file_path)}]
|
|
154
|
+
|
|
155
|
+
doc = pdfium.PdfDocument(file_path)
|
|
156
|
+
pages = []
|
|
157
|
+
for i in range(min(len(doc), max_pages)):
|
|
158
|
+
textpage = doc[i].get_textpage()
|
|
159
|
+
text = textpage.get_text_bounded()
|
|
160
|
+
textpage.close()
|
|
161
|
+
if text.strip():
|
|
162
|
+
pages.append({"page_number": i + 1, "text": text})
|
|
163
|
+
doc.close()
|
|
164
|
+
return pages
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _extract_docx(file_path: str) -> list[dict[str, Any]]:
|
|
168
|
+
try:
|
|
169
|
+
from docx import Document
|
|
170
|
+
except ImportError:
|
|
171
|
+
return [{"page_number": 1, "text": _read_file_text(file_path)}]
|
|
172
|
+
|
|
173
|
+
doc = Document(file_path)
|
|
174
|
+
text = "\n".join(p.text for p in doc.paragraphs if p.text.strip())
|
|
175
|
+
return [{"page_number": 1, "text": text}]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _extract_plain(file_path: str) -> list[dict[str, Any]]:
|
|
179
|
+
return [{"page_number": 1, "text": _read_file_text(file_path)}]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _read_file_text(file_path: str) -> str:
|
|
183
|
+
try:
|
|
184
|
+
with open(file_path, encoding="utf-8", errors="replace") as f:
|
|
185
|
+
return f.read()
|
|
186
|
+
except Exception:
|
|
187
|
+
return ""
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _extract_events_from_text(
|
|
191
|
+
text: str, page_number: int, source: str, focus: list[str] | None
|
|
192
|
+
) -> list[dict[str, Any]]:
|
|
193
|
+
if not text or len(text) < 20:
|
|
194
|
+
return []
|
|
195
|
+
|
|
196
|
+
sentences = re.split(r"[.!?\n]+", text)
|
|
197
|
+
events = []
|
|
198
|
+
|
|
199
|
+
for sentence in sentences:
|
|
200
|
+
sentence = sentence.strip()
|
|
201
|
+
if len(sentence) < 20:
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
event_type = None
|
|
205
|
+
for etype, pattern in _EVENT_PATTERNS.items():
|
|
206
|
+
if pattern.search(sentence):
|
|
207
|
+
event_type = etype
|
|
208
|
+
break
|
|
209
|
+
|
|
210
|
+
if event_type is None:
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
if focus and event_type not in focus:
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
competitor = _find_competitor(sentence)
|
|
217
|
+
date = _find_date(sentence)
|
|
218
|
+
|
|
219
|
+
events.append(
|
|
220
|
+
{
|
|
221
|
+
"event_type": event_type,
|
|
222
|
+
"date": date,
|
|
223
|
+
"competitor": competitor or "Unknown",
|
|
224
|
+
"product": None,
|
|
225
|
+
"description": sentence[:300],
|
|
226
|
+
"source": source,
|
|
227
|
+
"source_page": page_number,
|
|
228
|
+
"confidence": "medium" if competitor else "low",
|
|
229
|
+
}
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
return events
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _find_competitor(text: str) -> str | None:
|
|
236
|
+
text_lower = text.lower()
|
|
237
|
+
for company in _PHARMA_COMPANIES:
|
|
238
|
+
if company in text_lower:
|
|
239
|
+
return company.title()
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _find_date(text: str) -> str | None:
|
|
244
|
+
m = _DATE_PATTERN.search(text)
|
|
245
|
+
if m:
|
|
246
|
+
return m.group(0)
|
|
247
|
+
return None
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""ci_landscape — competitive landscape matrix for a therapeutic area."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LandscapeArgs(BaseModel):
|
|
12
|
+
therapeutic_area: str = Field(description="Therapeutic area or indication, e.g. 'NSCLC', 'Type 2 Diabetes'")
|
|
13
|
+
competitors: list[str] | None = Field(
|
|
14
|
+
default=None,
|
|
15
|
+
description="Specific competitors to include. If omitted, discovers top sponsors from trial data.",
|
|
16
|
+
)
|
|
17
|
+
phases: list[str] | None = Field(
|
|
18
|
+
default=None,
|
|
19
|
+
description="Trial phases to include: phase1, phase2, phase3, approved. Default: all.",
|
|
20
|
+
)
|
|
21
|
+
include_approved: bool = Field(
|
|
22
|
+
default=True,
|
|
23
|
+
description="Include already-approved products from FDA label data.",
|
|
24
|
+
)
|
|
25
|
+
max_competitors: int = Field(
|
|
26
|
+
default=15,
|
|
27
|
+
ge=1,
|
|
28
|
+
le=50,
|
|
29
|
+
description="Maximum number of competitors in the landscape.",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
TOOL: dict[str, Any] = {
|
|
34
|
+
"name": "ci_landscape",
|
|
35
|
+
"description": (
|
|
36
|
+
"Build a competitive landscape matrix for a therapeutic area by "
|
|
37
|
+
"querying ClinicalTrials.gov and OpenFDA, cross-referencing trials "
|
|
38
|
+
"with approved products, deduplicating, and ranking by phase. "
|
|
39
|
+
"Returns a structured landscape with competitor, product, mechanism, "
|
|
40
|
+
"phase, key trials, and expected milestones."
|
|
41
|
+
),
|
|
42
|
+
"args": LandscapeArgs,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def handle(arguments: dict[str, Any]) -> list[dict[str, Any]]:
|
|
47
|
+
from datetime import datetime, timezone
|
|
48
|
+
|
|
49
|
+
from .ci_scan_trials import handle as scan_trials
|
|
50
|
+
|
|
51
|
+
therapeutic_area = arguments["therapeutic_area"]
|
|
52
|
+
target_competitors = arguments.get("competitors")
|
|
53
|
+
include_approved = arguments.get("include_approved", True)
|
|
54
|
+
max_competitors = arguments.get("max_competitors", 15)
|
|
55
|
+
|
|
56
|
+
trial_result = json.loads(scan_trials({"query": therapeutic_area, "max_results": 50})[0]["text"])
|
|
57
|
+
trials = trial_result.get("trials", [])
|
|
58
|
+
|
|
59
|
+
sponsor_products: dict[str, dict[str, dict[str, Any]]] = {}
|
|
60
|
+
|
|
61
|
+
for t in trials:
|
|
62
|
+
sponsor = t.get("sponsor", "Unknown")
|
|
63
|
+
if target_competitors and not any(c.lower() in sponsor.lower() for c in target_competitors):
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
for intervention in t.get("interventions", []):
|
|
67
|
+
key = f"{sponsor}|{intervention}"
|
|
68
|
+
if key not in sponsor_products.get(sponsor, {}):
|
|
69
|
+
sponsor_products.setdefault(sponsor, {})[intervention] = {
|
|
70
|
+
"sponsor": sponsor,
|
|
71
|
+
"product": intervention,
|
|
72
|
+
"phase": t.get("phase", ""),
|
|
73
|
+
"status": t.get("status", ""),
|
|
74
|
+
"conditions": t.get("conditions", []),
|
|
75
|
+
"key_trials": [t.get("nct_id", "")],
|
|
76
|
+
"enrollment": t.get("enrollment"),
|
|
77
|
+
"completion_date": t.get("completion_date", ""),
|
|
78
|
+
"start_date": t.get("start_date", ""),
|
|
79
|
+
}
|
|
80
|
+
else:
|
|
81
|
+
entry = sponsor_products[sponsor][intervention]
|
|
82
|
+
if t.get("nct_id") and t["nct_id"] not in entry["key_trials"]:
|
|
83
|
+
entry["key_trials"].append(t["nct_id"])
|
|
84
|
+
new_phase = _phase_rank(t.get("phase", ""))
|
|
85
|
+
if new_phase > _phase_rank(entry["phase"]):
|
|
86
|
+
entry["phase"] = t.get("phase", "")
|
|
87
|
+
entry["status"] = t.get("status", "")
|
|
88
|
+
|
|
89
|
+
approved_products: dict[str, dict[str, Any]] = {}
|
|
90
|
+
if include_approved:
|
|
91
|
+
from .ci_scan_regulatory import handle as scan_reg
|
|
92
|
+
|
|
93
|
+
reg_result = json.loads(scan_reg({"drug_name": therapeutic_area, "max_results": 30})[0]["text"])
|
|
94
|
+
for event in reg_result.get("events", []):
|
|
95
|
+
if event.get("type") == "approval":
|
|
96
|
+
brand = event.get("brand_name", "")
|
|
97
|
+
sponsor = event.get("generic_name", brand)
|
|
98
|
+
if brand and brand not in approved_products:
|
|
99
|
+
approved_products[brand] = {
|
|
100
|
+
"sponsor": sponsor,
|
|
101
|
+
"product": brand,
|
|
102
|
+
"phase": "APPROVED",
|
|
103
|
+
"status": "marketed",
|
|
104
|
+
"conditions": [],
|
|
105
|
+
"key_trials": [],
|
|
106
|
+
"approval_date": event.get("date", ""),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
entries = []
|
|
110
|
+
for sponsor_data in sponsor_products.values():
|
|
111
|
+
for entry in sponsor_data.values():
|
|
112
|
+
milestones = []
|
|
113
|
+
if entry.get("completion_date"):
|
|
114
|
+
milestones.append({"event": "Primary completion", "date": entry["completion_date"]})
|
|
115
|
+
entries.append(
|
|
116
|
+
{
|
|
117
|
+
"competitor": entry["sponsor"],
|
|
118
|
+
"product": entry["product"],
|
|
119
|
+
"mechanism": None,
|
|
120
|
+
"phase": entry["phase"],
|
|
121
|
+
"status": entry["status"],
|
|
122
|
+
"indications": entry.get("conditions", [])[:3],
|
|
123
|
+
"key_trials": entry["key_trials"][:3],
|
|
124
|
+
"expected_milestones": milestones,
|
|
125
|
+
"differentiators": [],
|
|
126
|
+
"approval_date": None,
|
|
127
|
+
}
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
for brand, info in approved_products.items():
|
|
131
|
+
generic = info.get("sponsor", "").lower()
|
|
132
|
+
existing = any(
|
|
133
|
+
e["product"].lower() == brand.lower()
|
|
134
|
+
or e["product"].lower() == generic
|
|
135
|
+
or brand.lower() in e["product"].lower()
|
|
136
|
+
for e in entries
|
|
137
|
+
)
|
|
138
|
+
if not existing:
|
|
139
|
+
entries.append(
|
|
140
|
+
{
|
|
141
|
+
"competitor": info["sponsor"],
|
|
142
|
+
"product": info["product"],
|
|
143
|
+
"mechanism": None,
|
|
144
|
+
"phase": "APPROVED",
|
|
145
|
+
"status": "marketed",
|
|
146
|
+
"indications": [],
|
|
147
|
+
"key_trials": [],
|
|
148
|
+
"expected_milestones": [],
|
|
149
|
+
"differentiators": [],
|
|
150
|
+
"approval_date": info.get("approval_date"),
|
|
151
|
+
}
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
entries.sort(key=lambda e: _phase_rank(e["phase"]), reverse=True)
|
|
155
|
+
entries = entries[:max_competitors]
|
|
156
|
+
|
|
157
|
+
phase_dist: dict[str, int] = {}
|
|
158
|
+
for e in entries:
|
|
159
|
+
p = e["phase"] or "Unknown"
|
|
160
|
+
phase_dist[p] = phase_dist.get(p, 0) + 1
|
|
161
|
+
|
|
162
|
+
output = {
|
|
163
|
+
"therapeutic_area": therapeutic_area,
|
|
164
|
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
165
|
+
"total_entries": len(entries),
|
|
166
|
+
"phase_distribution": phase_dist,
|
|
167
|
+
"entries": entries,
|
|
168
|
+
"data_sources": ["ClinicalTrials.gov", "OpenFDA"],
|
|
169
|
+
"metadata": {
|
|
170
|
+
"total_trials_scanned": len(trials),
|
|
171
|
+
"total_approved_found": len(approved_products),
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
return [{"type": "text", "text": json.dumps(output, indent=2)}]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _phase_rank(phase: str) -> int:
|
|
178
|
+
p = phase.upper().replace(", ", "").replace(" ", "")
|
|
179
|
+
if "APPROVED" in p:
|
|
180
|
+
return 10
|
|
181
|
+
if "PHASE4" in p:
|
|
182
|
+
return 9
|
|
183
|
+
if "PHASE3" in p:
|
|
184
|
+
return 8
|
|
185
|
+
if "PHASE2" in p and "PHASE3" in p:
|
|
186
|
+
return 7
|
|
187
|
+
if "PHASE2" in p:
|
|
188
|
+
return 6
|
|
189
|
+
if "PHASE1" in p and "PHASE2" in p:
|
|
190
|
+
return 5
|
|
191
|
+
if "PHASE1" in p:
|
|
192
|
+
return 4
|
|
193
|
+
if "EARLY" in p:
|
|
194
|
+
return 3
|
|
195
|
+
return 1
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""ci_refresh — collect one immutable evidence run for tracked entities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from .._runs import RunIntegrityError, create_run
|
|
11
|
+
from .._watchlist import WatchlistError, load_tracked_entities
|
|
12
|
+
from ..models import RefreshRequest, SectionName, aggregate_coverage
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RefreshArgs(BaseModel):
|
|
16
|
+
entities: list[str] | None = Field(
|
|
17
|
+
default=None,
|
|
18
|
+
description="Optional tracked entity names; omit to refresh the full watchlist",
|
|
19
|
+
)
|
|
20
|
+
include_sections: list[SectionName] = Field(
|
|
21
|
+
default_factory=lambda: ["trials", "regulatory", "news", "publications"]
|
|
22
|
+
)
|
|
23
|
+
news_days_back: int = Field(default=90, ge=1, le=365)
|
|
24
|
+
publication_days_back: int = Field(default=365, ge=1, le=1825)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
TOOL: dict[str, Any] = {
|
|
28
|
+
"name": "ci_refresh",
|
|
29
|
+
"description": (
|
|
30
|
+
"Collect one immutable, evidence-bearing Competitive Intelligence run "
|
|
31
|
+
"for selected tracked entities or the full watchlist."
|
|
32
|
+
),
|
|
33
|
+
"args": RefreshArgs,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def handle(arguments: dict[str, Any]) -> list[dict[str, Any]]:
|
|
38
|
+
args = RefreshArgs.model_validate(arguments)
|
|
39
|
+
try:
|
|
40
|
+
watchlist = load_tracked_entities()
|
|
41
|
+
except WatchlistError as error:
|
|
42
|
+
return _error("invalid_watchlist", str(error))
|
|
43
|
+
|
|
44
|
+
selected = watchlist
|
|
45
|
+
if args.entities is not None:
|
|
46
|
+
by_name = {entity.name.casefold(): entity for entity in watchlist}
|
|
47
|
+
requested = list(dict.fromkeys(name.strip().casefold() for name in args.entities if name.strip()))
|
|
48
|
+
unknown = [name for name in args.entities if name.strip().casefold() not in by_name]
|
|
49
|
+
if unknown:
|
|
50
|
+
return _error(
|
|
51
|
+
"unknown_entity",
|
|
52
|
+
"Track each entity with ci_track action=add and an explicit drug or company type before refresh.",
|
|
53
|
+
entities=list(dict.fromkeys(unknown)),
|
|
54
|
+
)
|
|
55
|
+
selected = [by_name[name] for name in requested]
|
|
56
|
+
if not selected:
|
|
57
|
+
return _error(
|
|
58
|
+
"empty_watchlist",
|
|
59
|
+
"Track at least one explicit drug or company with ci_track action=add before refresh.",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
request = RefreshRequest(
|
|
63
|
+
entities=selected,
|
|
64
|
+
include_sections=args.include_sections,
|
|
65
|
+
news_days_back=args.news_days_back,
|
|
66
|
+
publication_days_back=args.publication_days_back,
|
|
67
|
+
)
|
|
68
|
+
try:
|
|
69
|
+
run = create_run(request)
|
|
70
|
+
except RunIntegrityError as error:
|
|
71
|
+
return _error("run_persistence_failed", str(error))
|
|
72
|
+
|
|
73
|
+
coverage = [
|
|
74
|
+
{
|
|
75
|
+
"entity": snapshot.entity.name,
|
|
76
|
+
"source": result.source.value,
|
|
77
|
+
"status": result.status.value,
|
|
78
|
+
"record_count": len(result.records),
|
|
79
|
+
"limitations": result.limitations,
|
|
80
|
+
}
|
|
81
|
+
for snapshot in run.entities
|
|
82
|
+
for result in snapshot.sources.values()
|
|
83
|
+
]
|
|
84
|
+
status = aggregate_coverage([result.status for snapshot in run.entities for result in snapshot.sources.values()])
|
|
85
|
+
output = {
|
|
86
|
+
"success": True,
|
|
87
|
+
"run_id": run.run_id,
|
|
88
|
+
"manifest_path": run.manifest_path,
|
|
89
|
+
"records_sha256": run.records_sha256,
|
|
90
|
+
"entities": [snapshot.entity.name for snapshot in run.entities],
|
|
91
|
+
"include_sections": run.request.include_sections,
|
|
92
|
+
"coverage_status": status.value,
|
|
93
|
+
"coverage": coverage,
|
|
94
|
+
"limitations": run.limitations,
|
|
95
|
+
}
|
|
96
|
+
return [{"type": "text", "text": json.dumps(output, indent=2)}]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _error(code: str, message: str, **details: Any) -> list[dict[str, Any]]:
|
|
100
|
+
payload = {"error": {"code": code, "message": message, **details}}
|
|
101
|
+
return [{"type": "text", "text": json.dumps(payload, indent=2)}]
|