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