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