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,402 @@
1
+ """ci_report — project immutable evidence runs into safe briefing artifacts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import io
7
+ import json
8
+ from datetime import datetime, timezone
9
+ from html import escape
10
+ from typing import Any, Sequence
11
+ from urllib.parse import urlsplit
12
+
13
+ from pydantic import BaseModel, Field, model_validator
14
+
15
+ from shared.filesystem import sanitize_url
16
+
17
+ from .._artifacts import (
18
+ create_artifact_dir,
19
+ safe_csv_cell,
20
+ sanitize_display_stem,
21
+ write_artifact,
22
+ write_manifest,
23
+ )
24
+ from .._runs import RunIntegrityError, create_run, load_run
25
+ from .._watchlist import WatchlistError, load_tracked_entities
26
+ from ..models import (
27
+ ArtifactManifest,
28
+ CIRun,
29
+ RefreshRequest,
30
+ ReportArtifactResult,
31
+ SectionName,
32
+ SourceName,
33
+ TrackedEntity,
34
+ aggregate_coverage,
35
+ )
36
+
37
+ _SECTION_ORDER: tuple[SectionName, ...] = ("trials", "regulatory", "news", "publications")
38
+
39
+
40
+ class ReportArgs(BaseModel):
41
+ run_id: str | None = None
42
+ focus: str | None = Field(
43
+ default=None,
44
+ description="Specific drug/company to report on, or omit for full watchlist",
45
+ )
46
+ include_sections: list[SectionName] = Field(default_factory=lambda: list(_SECTION_ORDER))
47
+ file_name: str | None = Field(default=None, description="Display filename stem")
48
+
49
+ @model_validator(mode="after")
50
+ def validate_collection_selector(self) -> "ReportArgs":
51
+ if self.run_id and self.focus:
52
+ raise ValueError("run_id cannot be combined with focus")
53
+ return self
54
+
55
+
56
+ TOOL: dict[str, Any] = {
57
+ "name": "ci_report",
58
+ "description": (
59
+ "Generate immutable JSON, HTML, and CSV briefing artifacts from an existing "
60
+ "evidence run or from one new watchlist collection pass."
61
+ ),
62
+ "args": ReportArgs,
63
+ }
64
+
65
+
66
+ def handle(arguments: dict[str, Any]) -> list[dict[str, Any]]:
67
+ args = ReportArgs.model_validate(arguments)
68
+ extra_limitations: list[str] = []
69
+ if args.run_id:
70
+ try:
71
+ run = load_run(args.run_id)
72
+ except RunIntegrityError as error:
73
+ return _error("invalid_run", str(error))
74
+ else:
75
+ try:
76
+ entities = load_tracked_entities()
77
+ except WatchlistError as error:
78
+ return _error("invalid_watchlist", str(error))
79
+ if args.focus:
80
+ match = next(
81
+ (entity for entity in entities if entity.name.casefold() == args.focus.casefold()),
82
+ None,
83
+ )
84
+ if match is None:
85
+ match = TrackedEntity(entity_type="drug", name=args.focus)
86
+ extra_limitations.append(
87
+ "identity_assumed_drug: unmatched legacy focus was treated as a transient drug and was not tracked."
88
+ )
89
+ entities = [match]
90
+ if not entities:
91
+ return _error(
92
+ "no_entities",
93
+ "Use ci_track to add an explicit drug or company, or pass focus.",
94
+ )
95
+ run = create_run(RefreshRequest(entities=entities, include_sections=args.include_sections))
96
+
97
+ try:
98
+ result = write_report_artifacts(
99
+ run,
100
+ include_sections=args.include_sections,
101
+ display_stem=args.file_name or "briefing",
102
+ additional_limitations=extra_limitations,
103
+ )
104
+ except (OSError, ValueError) as error:
105
+ return _error("artifact_write_failed", str(error))
106
+ coverage = _coverage_rows(run, args.include_sections)
107
+ coverage_status = aggregate_coverage([row["status"] for row in coverage])
108
+ output = {
109
+ "success": True,
110
+ "artifact_status": "complete",
111
+ "coverage_status": coverage_status.value,
112
+ "run_id": run.run_id,
113
+ "run_records_sha256": run.records_sha256,
114
+ "manifest_path": result.manifest_path,
115
+ "json_path": result.json_path,
116
+ "html_path": result.html_path,
117
+ "csv_files": result.csv_files,
118
+ "entities_covered": [snapshot.entity.name for snapshot in run.entities],
119
+ "sections": args.include_sections,
120
+ "coverage": [{**row, "status": row["status"].value} for row in coverage],
121
+ "limitations": [*run.limitations, *extra_limitations],
122
+ }
123
+ return [{"type": "text", "text": json.dumps(output, indent=2)}]
124
+
125
+
126
+ def write_report_artifacts(
127
+ run: CIRun,
128
+ *,
129
+ include_sections: Sequence[SectionName] | None = None,
130
+ display_stem: str = "briefing",
131
+ now: datetime | None = None,
132
+ additional_limitations: Sequence[str] = (),
133
+ ) -> ReportArtifactResult:
134
+ generated_at = _utc(now)
135
+ sections = _normalize_sections(include_sections or run.request.include_sections)
136
+ stem = sanitize_display_stem(display_stem, default="briefing")
137
+ output_dir = create_artifact_dir(run.run_id, now=generated_at)
138
+ report = _project_report(run, sections, generated_at, additional_limitations)
139
+ artifacts = []
140
+
141
+ json_name = f"{stem}.json"
142
+ artifacts.append(
143
+ write_artifact(
144
+ output_dir,
145
+ json_name,
146
+ json.dumps(report, indent=2, ensure_ascii=False),
147
+ media_type="application/json",
148
+ )
149
+ )
150
+ html_name = f"{stem}.html"
151
+ artifacts.append(
152
+ write_artifact(
153
+ output_dir,
154
+ html_name,
155
+ _render_html(report),
156
+ media_type="text/html; charset=utf-8",
157
+ )
158
+ )
159
+
160
+ csv_paths = []
161
+ for section in sections:
162
+ rows = _csv_rows(run, section)
163
+ if not rows:
164
+ continue
165
+ csv_name = f"{stem}_{section}.csv"
166
+ csv_bytes = _csv_bytes(rows)
167
+ artifacts.append(
168
+ write_artifact(
169
+ output_dir,
170
+ csv_name,
171
+ csv_bytes,
172
+ media_type="text/csv; charset=utf-8",
173
+ )
174
+ )
175
+ csv_paths.append(str(output_dir / csv_name))
176
+
177
+ manifest = ArtifactManifest(
178
+ artifact_id=output_dir.name,
179
+ run_id=run.run_id,
180
+ run_records_sha256=run.records_sha256,
181
+ generated_at=generated_at,
182
+ display_stem=stem,
183
+ artifacts=artifacts,
184
+ )
185
+ manifest_path = write_manifest(output_dir, manifest)
186
+ return ReportArtifactResult(
187
+ output_dir=str(output_dir),
188
+ json_path=str(output_dir / json_name),
189
+ html_path=str(output_dir / html_name),
190
+ csv_files=csv_paths,
191
+ manifest_path=str(manifest_path),
192
+ manifest=manifest,
193
+ )
194
+
195
+
196
+ def _project_report(
197
+ run: CIRun,
198
+ sections: Sequence[SectionName],
199
+ generated_at: datetime,
200
+ additional_limitations: Sequence[str],
201
+ ) -> dict[str, Any]:
202
+ projected: dict[str, Any] = {
203
+ "schema_version": 1,
204
+ "title": "Competitive Intelligence Briefing",
205
+ "generated_at": generated_at.isoformat(),
206
+ "run_id": run.run_id,
207
+ "run_records_sha256": run.records_sha256,
208
+ "entities": [snapshot.entity.name for snapshot in run.entities],
209
+ "requested_sections": list(sections),
210
+ "sections": {},
211
+ "coverage": [],
212
+ "limitations": [*run.limitations, *additional_limitations],
213
+ }
214
+ for snapshot in run.entities:
215
+ entity_data: dict[str, Any] = {
216
+ "entity": snapshot.entity.name,
217
+ "entity_type": snapshot.entity.entity_type,
218
+ }
219
+ for section in sections:
220
+ sources = _sources_for_section(snapshot.sources, section)
221
+ if not sources:
222
+ projected["limitations"].append(
223
+ f"{snapshot.entity.name}/{section}: section was not present in the selected run."
224
+ )
225
+ continue
226
+ records = [record for result in sources for record in result.records]
227
+ status = aggregate_coverage([result.status for result in sources])
228
+ section_data = {
229
+ "coverage": status.value,
230
+ "records": records,
231
+ "returned": len(records),
232
+ "total_available": sum(result.total_available or 0 for result in sources),
233
+ "source_ledger": [
234
+ evidence.model_dump(mode="json") for result in sources for evidence in result.requests
235
+ ],
236
+ "limitations": [limitation for result in sources for limitation in result.limitations],
237
+ }
238
+ if section == "trials":
239
+ section_data.update({"trials": records, "total_found": sources[0].total_available})
240
+ elif section == "regulatory":
241
+ section_data.update({"events": sources[0].records, "total_events": len(records)})
242
+ if len(sources) > 1:
243
+ section_data["label_history"] = sources[1].records
244
+ elif section == "news":
245
+ section_data.update({"results": records, "total_results": len(records)})
246
+ else:
247
+ section_data.update({"publications": records, "total_found": sources[0].total_available})
248
+ entity_data[section] = section_data
249
+ projected["coverage"].extend(
250
+ {
251
+ "entity": snapshot.entity.name,
252
+ "source": result.source.value,
253
+ "status": result.status.value,
254
+ "record_count": len(result.records),
255
+ "query": result.query,
256
+ "retrieved_at": result.retrieved_at.isoformat(),
257
+ "cache": result.cache.status.value,
258
+ "limitations": result.limitations,
259
+ }
260
+ for result in sources
261
+ )
262
+ projected["sections"][snapshot.entity.name] = entity_data
263
+ return projected
264
+
265
+
266
+ def _sources_for_section(sources, section: SectionName):
267
+ keys = {
268
+ "trials": [SourceName.CLINICAL_TRIALS.value],
269
+ "regulatory": [SourceName.OPENFDA.value, SourceName.DAILYMED.value],
270
+ "news": [SourceName.WEB.value],
271
+ "publications": [SourceName.PUBMED.value],
272
+ }[section]
273
+ return [sources[key] for key in keys if key in sources]
274
+
275
+
276
+ def _coverage_rows(run: CIRun, sections: Sequence[SectionName]) -> list[dict[str, Any]]:
277
+ return [
278
+ {
279
+ "entity": snapshot.entity.name,
280
+ "source": result.source.value,
281
+ "status": result.status,
282
+ "record_count": len(result.records),
283
+ "limitations": result.limitations,
284
+ }
285
+ for snapshot in run.entities
286
+ for section in sections
287
+ for result in _sources_for_section(snapshot.sources, section)
288
+ ]
289
+
290
+
291
+ def _csv_rows(run: CIRun, section: SectionName) -> list[dict[str, Any]]:
292
+ rows = []
293
+ for snapshot in run.entities:
294
+ for result in _sources_for_section(snapshot.sources, section):
295
+ for record in result.records:
296
+ rows.append(
297
+ {
298
+ "entity": snapshot.entity.name,
299
+ "source": result.source.value,
300
+ **record,
301
+ }
302
+ )
303
+ return rows
304
+
305
+
306
+ def _csv_bytes(rows: list[dict[str, Any]]) -> bytes:
307
+ fieldnames = list(dict.fromkeys(key for row in rows for key in row))
308
+ buffer = io.StringIO(newline="")
309
+ writer = csv.DictWriter(buffer, fieldnames=fieldnames, extrasaction="ignore")
310
+ writer.writeheader()
311
+ for row in rows:
312
+ writer.writerow(
313
+ {
314
+ key: safe_csv_cell(json.dumps(value, ensure_ascii=False) if isinstance(value, (dict, list)) else value)
315
+ for key, value in row.items()
316
+ }
317
+ )
318
+ return buffer.getvalue().encode("utf-8-sig")
319
+
320
+
321
+ def _render_html(report: dict[str, Any]) -> str:
322
+ parts = [
323
+ "<!DOCTYPE html><html lang='en'><head><meta charset='utf-8'>",
324
+ "<meta http-equiv='Content-Security-Policy' content=\"default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'\">",
325
+ "<meta name='viewport' content='width=device-width,initial-scale=1'>",
326
+ f"<title>{escape(report['title'])}</title>",
327
+ "<style>body{font-family:system-ui;margin:2rem;color:#172033}main{max-width:1100px;margin:auto}table{width:100%;border-collapse:collapse;margin:1rem 0}th,td{border:1px solid #dce3ed;padding:.5rem;text-align:left;vertical-align:top}th{background:#f2f5f9}.coverage{padding:.75rem;border-left:4px solid #466b9f;background:#f7f9fc;margin:.5rem 0}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:#174f91}</style>",
328
+ "</head><body><main>",
329
+ f"<h1>{escape(report['title'])}</h1>",
330
+ f"<p>Run <code>{escape(report['run_id'])}</code> · Generated {escape(report['generated_at'])}</p>",
331
+ "<h2>Source coverage</h2>",
332
+ ]
333
+ for row in report["coverage"]:
334
+ parts.append(
335
+ "<div class='coverage'>"
336
+ f"<strong>{escape(row['entity'])} · {escape(row['source'])}</strong>: "
337
+ f"{escape(row['status'])} · {row['record_count']} returned · "
338
+ f"cache {escape(row['cache'])}"
339
+ "</div>"
340
+ )
341
+ for entity_name, entity_data in report["sections"].items():
342
+ parts.append(f"<h2>{escape(entity_name)}</h2>")
343
+ for section in report["requested_sections"]:
344
+ section_data = entity_data.get(section)
345
+ if not section_data:
346
+ continue
347
+ parts.append(f"<h3>{escape(section.title())} · {escape(section_data['coverage'])}</h3>")
348
+ parts.append("<table><thead><tr><th>Source record</th></tr></thead><tbody>")
349
+ for record in section_data["records"]:
350
+ link = _record_link(record)
351
+ provenance = (
352
+ f"<p><a href='{escape(link, quote=True)}' rel='noopener noreferrer'>Source</a></p>" if link else ""
353
+ )
354
+ parts.append(
355
+ "<tr><td>"
356
+ f"<pre>{escape(json.dumps(record, indent=2, ensure_ascii=False))}</pre>"
357
+ f"{provenance}</td></tr>"
358
+ )
359
+ parts.append("</tbody></table>")
360
+ if report["limitations"]:
361
+ parts.append("<h2>Limitations</h2><ul>")
362
+ parts.extend(f"<li>{escape(value)}</li>" for value in report["limitations"])
363
+ parts.append("</ul>")
364
+ parts.append("</main></body></html>")
365
+ return "\n".join(parts)
366
+
367
+
368
+ def _record_link(record: dict[str, Any]) -> str | None:
369
+ for key in ("source_url", "url"):
370
+ value = record.get(key)
371
+ if not isinstance(value, str):
372
+ continue
373
+ parsed = urlsplit(value)
374
+ if (
375
+ parsed.scheme in {"http", "https"}
376
+ and parsed.netloc
377
+ and not parsed.username
378
+ and not parsed.password
379
+ and sanitize_url(value) == value
380
+ ):
381
+ return value
382
+ return None
383
+
384
+
385
+ def _normalize_sections(values: Sequence[SectionName]) -> list[SectionName]:
386
+ return [section for section in _SECTION_ORDER if section in values]
387
+
388
+
389
+ def _error(code: str, message: str) -> list[dict[str, Any]]:
390
+ return [
391
+ {
392
+ "type": "text",
393
+ "text": json.dumps({"error": {"code": code, "message": message}}, indent=2),
394
+ }
395
+ ]
396
+
397
+
398
+ def _utc(value: datetime | None) -> datetime:
399
+ now = value or datetime.now(timezone.utc)
400
+ if now.tzinfo is None:
401
+ raise ValueError("now must be timezone-aware")
402
+ return now.astimezone(timezone.utc)
@@ -0,0 +1,48 @@
1
+ """ci_scan_news — search for competitor news and press releases."""
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 .._web_search import search_news
11
+ from ..models import NewsSearchRequest
12
+
13
+
14
+ class ScanNewsArgs(BaseModel):
15
+ query: str = Field(description="Drug or company name to search for")
16
+ days_back: int = Field(default=90, ge=1, le=365, description="Search window in days")
17
+ max_results: int = Field(default=10, ge=1, le=30, description="Max results to return")
18
+
19
+
20
+ TOOL: dict[str, Any] = {
21
+ "name": "ci_scan_news",
22
+ "description": (
23
+ "Search the web for recent competitor news, press releases, and "
24
+ "conference presentations. Uses configured search backends "
25
+ "(Serper/Tavily/Exa) and reports explicit source coverage."
26
+ ),
27
+ "args": ScanNewsArgs,
28
+ }
29
+
30
+
31
+ def handle(arguments: dict[str, Any]) -> list[dict[str, Any]]:
32
+ user_request = NewsSearchRequest.model_validate(arguments)
33
+ exact_query = f"{user_request.query} pharmaceutical news OR press release OR FDA OR clinical trial"
34
+ provider_request = user_request.model_copy(update={"query": exact_query})
35
+ result = search_news(provider_request)
36
+ output = {
37
+ "query": user_request.query,
38
+ "exact_query": exact_query,
39
+ "days_back": user_request.days_back,
40
+ "total_results": len(result.records),
41
+ "results": result.records,
42
+ "coverage": result.status.value,
43
+ "source_ledger": [entry.model_dump(mode="json") for entry in result.requests],
44
+ "limitations": result.limitations,
45
+ }
46
+ if result.error:
47
+ output["error"] = result.error.model_dump(mode="json")
48
+ return [{"type": "text", "text": json.dumps(output, indent=2)}]
@@ -0,0 +1,46 @@
1
+ """ci_scan_publications — search PubMed for competitor clinical results."""
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 .._pubmed import search_publications
11
+ from ..models import PublicationSearchRequest
12
+
13
+
14
+ class ScanPublicationsArgs(BaseModel):
15
+ query: str = Field(description="Drug name or topic to search PubMed for")
16
+ days_back: int = Field(default=365, ge=1, le=1825, description="Search window in days")
17
+ max_results: int = Field(default=10, ge=1, le=30, description="Max publications to return")
18
+
19
+
20
+ TOOL: dict[str, Any] = {
21
+ "name": "ci_scan_publications",
22
+ "description": (
23
+ "Search PubMed for recent publications about a competitor drug or "
24
+ "therapeutic area. Returns structured publication data with title, "
25
+ "authors, journal, date, abstract excerpt, and explicit source coverage."
26
+ ),
27
+ "args": ScanPublicationsArgs,
28
+ }
29
+
30
+
31
+ def handle(arguments: dict[str, Any]) -> list[dict[str, Any]]:
32
+ request = PublicationSearchRequest.model_validate(arguments)
33
+ result = search_publications(request)
34
+ output = {
35
+ "query": request.query,
36
+ "exact_query": result.query,
37
+ "total_found": result.total_available,
38
+ "returned": len(result.records),
39
+ "publications": result.records,
40
+ "coverage": result.status.value,
41
+ "source_ledger": [entry.model_dump(mode="json") for entry in result.requests],
42
+ "limitations": result.limitations,
43
+ }
44
+ if result.error:
45
+ output["error"] = result.error.model_dump(mode="json")
46
+ return [{"type": "text", "text": json.dumps(output, indent=2)}]
@@ -0,0 +1,64 @@
1
+ """ci_scan_regulatory — scan openFDA and DailyMed with explicit coverage."""
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 .. import _regulatory
11
+ from ..models import RegulatorySearchRequest
12
+
13
+
14
+ class ScanRegulatoryArgs(BaseModel):
15
+ drug_name: str = Field(description="Drug brand or generic name to search")
16
+ date_from: str | None = Field(default=None, description="Start date (ISO, e.g. '2025-01-01')")
17
+ date_to: str | None = Field(default=None, description="End date (ISO, e.g. '2026-08-22')")
18
+ include_label_history: bool = Field(
19
+ default=True,
20
+ description="Include DailyMed SPL label version history",
21
+ )
22
+ max_results: int = Field(default=20, ge=1, le=50, description="Max events to return")
23
+
24
+
25
+ TOOL: dict[str, Any] = {
26
+ "name": "ci_scan_regulatory",
27
+ "description": (
28
+ "Scan openFDA for approvals, supplements, and submission status changes, "
29
+ "with optional DailyMed label-version history. Returns chronological events "
30
+ "and explicit coverage for each requested source."
31
+ ),
32
+ "args": ScanRegulatoryArgs,
33
+ }
34
+
35
+
36
+ def handle(arguments: dict[str, Any]) -> list[dict[str, Any]]:
37
+ request = RegulatorySearchRequest.model_validate(arguments)
38
+ result = _regulatory.scan_regulatory(request)
39
+ events = []
40
+ for event in result.events:
41
+ record = event.model_dump(mode="json")
42
+ record["type"] = record["event_type"]
43
+ events.append(record)
44
+ events.sort(key=lambda item: item.get("date") or "", reverse=True)
45
+ type_summary: dict[str, int] = {}
46
+ for event in events:
47
+ event_type = event["event_type"]
48
+ type_summary[event_type] = type_summary.get(event_type, 0) + 1
49
+ ledger = [result.openfda.model_dump(mode="json", exclude={"records"})]
50
+ if result.dailymed is not None:
51
+ ledger.append(result.dailymed.model_dump(mode="json", exclude={"records"}))
52
+ payload: dict[str, Any] = {
53
+ "drug_name": request.drug_name,
54
+ "total_events": len(events),
55
+ "type_summary": type_summary,
56
+ "events": events,
57
+ "coverage": result.coverage.value,
58
+ "source_ledger": ledger,
59
+ "limitations": result.limitations,
60
+ }
61
+ if result.label_history:
62
+ payload["label_history"] = [entry.model_dump(mode="json") for entry in result.label_history]
63
+ payload["label_versions"] = len(result.label_history)
64
+ return [{"type": "text", "text": json.dumps(payload, indent=2)}]
@@ -0,0 +1,85 @@
1
+ """ci_scan_trials — scan ClinicalTrials.gov for competitor trials."""
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 .. import _clinical_trials
11
+ from ..models import TrialSearchRequest
12
+
13
+
14
+ class ScanTrialsArgs(BaseModel):
15
+ query: str = Field(description="Drug name, company name, or NCT ID to search for")
16
+ phase: str | None = Field(default=None, description="Filter by phase: '1', '2', '3', '4'")
17
+ status: str | None = Field(
18
+ default=None,
19
+ description="Filter by status: RECRUITING, COMPLETED, ACTIVE_NOT_RECRUITING, etc.",
20
+ )
21
+ max_results: int = Field(default=20, ge=1, le=50, description="Max trials to return")
22
+
23
+
24
+ TOOL: dict[str, Any] = {
25
+ "name": "ci_scan_trials",
26
+ "description": (
27
+ "Scan ClinicalTrials.gov for clinical trials matching a competitor drug "
28
+ "or company. Returns a structured pipeline summary with trial phase, "
29
+ "status, sponsor, enrollment, expected completion dates, and source coverage."
30
+ ),
31
+ "args": ScanTrialsArgs,
32
+ }
33
+
34
+
35
+ def handle(arguments: dict[str, Any]) -> list[dict[str, Any]]:
36
+ request = TrialSearchRequest.model_validate(arguments)
37
+ result = _clinical_trials.search_trials(request)
38
+ trials = [_legacy_trial(record) for record in result.records]
39
+ phase_summary: dict[str, int] = {}
40
+ status_summary: dict[str, int] = {}
41
+ for trial in trials:
42
+ phase = trial["phase"] or "Not specified"
43
+ phase_summary[phase] = phase_summary.get(phase, 0) + 1
44
+ status = trial["status"]
45
+ status_summary[status] = status_summary.get(status, 0) + 1
46
+
47
+ output: dict[str, Any] = {
48
+ "query": request.query,
49
+ "total_found": result.total_available if result.total_available is not None else 0,
50
+ "returned": len(trials),
51
+ "phase_summary": phase_summary,
52
+ "status_summary": status_summary,
53
+ "trials": trials,
54
+ "coverage": result.status.value,
55
+ "source_ledger": [_source_ledger_entry(result)],
56
+ "limitations": result.limitations,
57
+ }
58
+ if result.error is not None:
59
+ output["error"] = result.error.message
60
+ return [{"type": "text", "text": json.dumps(output, indent=2)}]
61
+
62
+
63
+ def _legacy_trial(record: dict[str, Any]) -> dict[str, Any]:
64
+ interventions = record.get("interventions", [])
65
+ return {
66
+ "nct_id": record.get("nct_id", ""),
67
+ "title": record.get("title", ""),
68
+ "phase": record.get("phase", ""),
69
+ "status": record.get("status", ""),
70
+ "sponsor": record.get("sponsor", ""),
71
+ "collaborators": record.get("collaborators", []),
72
+ "conditions": record.get("conditions", [])[:3],
73
+ "interventions": [item.get("name", "") for item in interventions[:3]],
74
+ "intervention_details": interventions[:3],
75
+ "enrollment": record.get("enrollment"),
76
+ "start_date": record.get("start_date") or "",
77
+ "primary_completion_date": record.get("primary_completion_date") or "",
78
+ "completion_date": record.get("estimated_completion_date") or "",
79
+ "results_posted": bool(record.get("has_results")),
80
+ "source_url": record.get("source_url", ""),
81
+ }
82
+
83
+
84
+ def _source_ledger_entry(result) -> dict[str, Any]:
85
+ return result.model_dump(mode="json", exclude={"records"})