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,106 @@
|
|
|
1
|
+
"""ci_status — configuration and data source status."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class StatusArgs(BaseModel):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
TOOL: dict[str, Any] = {
|
|
15
|
+
"name": "ci_status",
|
|
16
|
+
"description": (
|
|
17
|
+
"Show the current configuration and data source status for "
|
|
18
|
+
"competitive intelligence: API key availability, cache stats, "
|
|
19
|
+
"watchlist summary, and optional dependency status."
|
|
20
|
+
),
|
|
21
|
+
"args": StatusArgs,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def handle(arguments: dict[str, Any]) -> list[dict[str, Any]]:
|
|
26
|
+
import json
|
|
27
|
+
|
|
28
|
+
from shared.env import get_env
|
|
29
|
+
|
|
30
|
+
from .._cache import cache_stats
|
|
31
|
+
from .._watchlist import WatchlistError, load_watchlist
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
watchlist = load_watchlist()
|
|
35
|
+
except WatchlistError as error:
|
|
36
|
+
return [
|
|
37
|
+
{
|
|
38
|
+
"type": "text",
|
|
39
|
+
"text": json.dumps(
|
|
40
|
+
{
|
|
41
|
+
"error": {
|
|
42
|
+
"code": "invalid_watchlist",
|
|
43
|
+
"message": str(error),
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
),
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
has_pdfplumber = False
|
|
51
|
+
try:
|
|
52
|
+
import pypdfium2 # noqa: F401
|
|
53
|
+
|
|
54
|
+
has_pdfplumber = True
|
|
55
|
+
except ImportError:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
has_docx = False
|
|
59
|
+
try:
|
|
60
|
+
import docx # noqa: F401
|
|
61
|
+
|
|
62
|
+
has_docx = True
|
|
63
|
+
except ImportError:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
output = {
|
|
67
|
+
"config": {
|
|
68
|
+
"OPENFDA_API_KEY": "set" if get_env("OPENFDA_API_KEY", "") else "not set",
|
|
69
|
+
"NCBI_API_KEY": "set" if get_env("NCBI_API_KEY", "") else "not set",
|
|
70
|
+
"SERPER_API_KEY": "set" if get_env("SERPER_API_KEY", "") else "not set",
|
|
71
|
+
"TAVILY_API_KEY": "set" if get_env("TAVILY_API_KEY", "") else "not set",
|
|
72
|
+
"EXA_API_KEY": "set" if get_env("EXA_API_KEY", "") else "not set",
|
|
73
|
+
"CI_CACHE_TTL_HOURS": get_env("CI_CACHE_TTL_HOURS", "24"),
|
|
74
|
+
},
|
|
75
|
+
"data_sources": {
|
|
76
|
+
"clinicaltrials_gov": {"url": "https://clinicaltrials.gov/api/v2", "auth": "none"},
|
|
77
|
+
"openfda": {
|
|
78
|
+
"url": "https://api.fda.gov/drug",
|
|
79
|
+
"auth": "optional API key",
|
|
80
|
+
"rate_limit": "240/min with key, 40/min without",
|
|
81
|
+
},
|
|
82
|
+
"dailymed": {"url": "https://dailymed.nlm.nih.gov/dailymed/services/v2", "auth": "none"},
|
|
83
|
+
"pubmed": {
|
|
84
|
+
"url": "https://eutils.ncbi.nlm.nih.gov/entrez/eutils",
|
|
85
|
+
"auth": "optional NCBI key",
|
|
86
|
+
"rate_limit": "10/s with key, 3/s without",
|
|
87
|
+
},
|
|
88
|
+
"web_search": {
|
|
89
|
+
"backends": "serper, tavily, exa",
|
|
90
|
+
"available": bool(
|
|
91
|
+
get_env("SERPER_API_KEY", "") or get_env("TAVILY_API_KEY", "") or get_env("EXA_API_KEY", "")
|
|
92
|
+
),
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
"cache": cache_stats(),
|
|
96
|
+
"watchlist": {
|
|
97
|
+
"total_entities": len(watchlist),
|
|
98
|
+
"drugs": sum(1 for e in watchlist if e.get("entity_type") == "drug"),
|
|
99
|
+
"companies": sum(1 for e in watchlist if e.get("entity_type") == "company"),
|
|
100
|
+
},
|
|
101
|
+
"optional_deps": {
|
|
102
|
+
"pypdfium2": has_pdfplumber,
|
|
103
|
+
"python_docx": has_docx,
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
return [{"type": "text", "text": json.dumps(output, indent=2)}]
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
"""ci_timeline — project a run into a filtered, DOM-safe HTML timeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import calendar
|
|
6
|
+
import json
|
|
7
|
+
from datetime import date, datetime, timezone
|
|
8
|
+
from html import escape
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field, model_validator
|
|
12
|
+
|
|
13
|
+
from shared.filesystem import json_for_html_script
|
|
14
|
+
|
|
15
|
+
from .._artifacts import (
|
|
16
|
+
create_artifact_dir,
|
|
17
|
+
sanitize_display_stem,
|
|
18
|
+
write_artifact,
|
|
19
|
+
write_manifest,
|
|
20
|
+
)
|
|
21
|
+
from .._runs import RunIntegrityError, create_run, load_run
|
|
22
|
+
from .._watchlist import WatchlistError, load_tracked_entities
|
|
23
|
+
from ..models import (
|
|
24
|
+
ArtifactManifest,
|
|
25
|
+
CIRun,
|
|
26
|
+
RefreshRequest,
|
|
27
|
+
SourceName,
|
|
28
|
+
TimelineArtifactResult,
|
|
29
|
+
TrackedEntity,
|
|
30
|
+
aggregate_coverage,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TimelineArgs(BaseModel):
|
|
35
|
+
run_id: str | None = None
|
|
36
|
+
entities: list[str] | None = Field(
|
|
37
|
+
default=None,
|
|
38
|
+
description="Tracked drug/company names, or omit for the full watchlist",
|
|
39
|
+
)
|
|
40
|
+
months_back: int = Field(default=12, ge=1, le=60)
|
|
41
|
+
file_name: str | None = Field(default=None, description="Display filename stem")
|
|
42
|
+
|
|
43
|
+
@model_validator(mode="after")
|
|
44
|
+
def validate_collection_selector(self) -> "TimelineArgs":
|
|
45
|
+
if self.run_id and self.entities:
|
|
46
|
+
raise ValueError("run_id cannot be combined with entities")
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
TOOL: dict[str, Any] = {
|
|
51
|
+
"name": "ci_timeline",
|
|
52
|
+
"description": (
|
|
53
|
+
"Generate a calendar-filtered, DOM-safe HTML timeline from an existing "
|
|
54
|
+
"evidence run or one new watchlist collection pass."
|
|
55
|
+
),
|
|
56
|
+
"args": TimelineArgs,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def handle(arguments: dict[str, Any]) -> list[dict[str, Any]]:
|
|
61
|
+
args = TimelineArgs.model_validate(arguments)
|
|
62
|
+
extra_limitations: list[str] = []
|
|
63
|
+
if args.run_id:
|
|
64
|
+
try:
|
|
65
|
+
run = load_run(args.run_id)
|
|
66
|
+
except RunIntegrityError as error:
|
|
67
|
+
return _error("invalid_run", str(error))
|
|
68
|
+
else:
|
|
69
|
+
try:
|
|
70
|
+
watchlist = load_tracked_entities()
|
|
71
|
+
except WatchlistError as error:
|
|
72
|
+
return _error("invalid_watchlist", str(error))
|
|
73
|
+
selected = watchlist
|
|
74
|
+
if args.entities:
|
|
75
|
+
by_name = {entity.name.casefold(): entity for entity in watchlist}
|
|
76
|
+
selected = []
|
|
77
|
+
seen = set()
|
|
78
|
+
for name in args.entities:
|
|
79
|
+
normalized = name.strip().casefold()
|
|
80
|
+
if not normalized or normalized in seen:
|
|
81
|
+
continue
|
|
82
|
+
seen.add(normalized)
|
|
83
|
+
entity = by_name.get(normalized)
|
|
84
|
+
if entity is None:
|
|
85
|
+
entity = TrackedEntity(entity_type="drug", name=name)
|
|
86
|
+
extra_limitations.append(
|
|
87
|
+
f"identity_assumed_drug: unmatched legacy entity {name!r} was transient and was not tracked."
|
|
88
|
+
)
|
|
89
|
+
selected.append(entity)
|
|
90
|
+
if not selected:
|
|
91
|
+
return _error(
|
|
92
|
+
"no_entities",
|
|
93
|
+
"Use ci_track to add an explicit drug or company, or pass entities.",
|
|
94
|
+
)
|
|
95
|
+
run = create_run(
|
|
96
|
+
RefreshRequest(
|
|
97
|
+
entities=selected,
|
|
98
|
+
include_sections=["trials", "regulatory", "publications"],
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
result = write_timeline_artifact(
|
|
104
|
+
run,
|
|
105
|
+
months_back=args.months_back,
|
|
106
|
+
display_stem=args.file_name or "timeline",
|
|
107
|
+
additional_limitations=extra_limitations,
|
|
108
|
+
)
|
|
109
|
+
except (OSError, ValueError) as error:
|
|
110
|
+
return _error("artifact_write_failed", str(error))
|
|
111
|
+
coverage = [
|
|
112
|
+
{
|
|
113
|
+
"entity": snapshot.entity.name,
|
|
114
|
+
"source": result.source.value,
|
|
115
|
+
"status": result.status.value,
|
|
116
|
+
"record_count": len(result.records),
|
|
117
|
+
"limitations": result.limitations,
|
|
118
|
+
}
|
|
119
|
+
for snapshot in run.entities
|
|
120
|
+
for result in snapshot.sources.values()
|
|
121
|
+
if result.source != SourceName.WEB
|
|
122
|
+
]
|
|
123
|
+
coverage_status = aggregate_coverage(
|
|
124
|
+
[
|
|
125
|
+
result.status
|
|
126
|
+
for snapshot in run.entities
|
|
127
|
+
for result in snapshot.sources.values()
|
|
128
|
+
if result.source != SourceName.WEB
|
|
129
|
+
]
|
|
130
|
+
)
|
|
131
|
+
output = {
|
|
132
|
+
"success": True,
|
|
133
|
+
"artifact_status": "complete",
|
|
134
|
+
"coverage_status": coverage_status.value,
|
|
135
|
+
"run_id": run.run_id,
|
|
136
|
+
"run_records_sha256": run.records_sha256,
|
|
137
|
+
"manifest_path": result.manifest_path,
|
|
138
|
+
"html_path": result.html_path,
|
|
139
|
+
"included_events": result.included_events,
|
|
140
|
+
"excluded_old_events": result.excluded_old_events,
|
|
141
|
+
"excluded_undated_events": result.excluded_undated_events,
|
|
142
|
+
"total_events": result.included_events,
|
|
143
|
+
"entities": [snapshot.entity.name for snapshot in run.entities],
|
|
144
|
+
"coverage": coverage,
|
|
145
|
+
"limitations": [*run.limitations, *extra_limitations],
|
|
146
|
+
}
|
|
147
|
+
return [{"type": "text", "text": json.dumps(output, indent=2)}]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def write_timeline_artifact(
|
|
151
|
+
run: CIRun,
|
|
152
|
+
*,
|
|
153
|
+
months_back: int,
|
|
154
|
+
display_stem: str = "timeline",
|
|
155
|
+
now: datetime | None = None,
|
|
156
|
+
additional_limitations: list[str] | None = None,
|
|
157
|
+
) -> TimelineArtifactResult:
|
|
158
|
+
generated_at = _utc(now)
|
|
159
|
+
cutoff = calendar_month_cutoff(generated_at, months_back)
|
|
160
|
+
all_events = events_from_run(run)
|
|
161
|
+
included = []
|
|
162
|
+
excluded_old = 0
|
|
163
|
+
excluded_undated = 0
|
|
164
|
+
for event in all_events:
|
|
165
|
+
normalized, precision = normalize_event_date(event.get("raw_date"))
|
|
166
|
+
if normalized is None:
|
|
167
|
+
excluded_undated += 1
|
|
168
|
+
continue
|
|
169
|
+
if normalized < cutoff:
|
|
170
|
+
excluded_old += 1
|
|
171
|
+
continue
|
|
172
|
+
included.append(
|
|
173
|
+
{
|
|
174
|
+
**event,
|
|
175
|
+
"date": normalized.isoformat(),
|
|
176
|
+
"date_precision": precision,
|
|
177
|
+
}
|
|
178
|
+
)
|
|
179
|
+
included.sort(key=lambda event: (event["date"], event["entity"], event["id"]), reverse=True)
|
|
180
|
+
|
|
181
|
+
stem = sanitize_display_stem(display_stem, default="timeline")
|
|
182
|
+
output_dir = create_artifact_dir(run.run_id, now=generated_at)
|
|
183
|
+
html_name = f"{stem}.html"
|
|
184
|
+
coverage = [
|
|
185
|
+
{
|
|
186
|
+
"entity": snapshot.entity.name,
|
|
187
|
+
"source": result.source.value,
|
|
188
|
+
"status": result.status.value,
|
|
189
|
+
"record_count": len(result.records),
|
|
190
|
+
"limitations": result.limitations,
|
|
191
|
+
}
|
|
192
|
+
for snapshot in run.entities
|
|
193
|
+
for result in snapshot.sources.values()
|
|
194
|
+
]
|
|
195
|
+
html_record = write_artifact(
|
|
196
|
+
output_dir,
|
|
197
|
+
html_name,
|
|
198
|
+
render_timeline_html(
|
|
199
|
+
included,
|
|
200
|
+
[snapshot.entity.name for snapshot in run.entities],
|
|
201
|
+
coverage,
|
|
202
|
+
[*run.limitations, *(additional_limitations or [])],
|
|
203
|
+
excluded_old=excluded_old,
|
|
204
|
+
excluded_undated=excluded_undated,
|
|
205
|
+
),
|
|
206
|
+
media_type="text/html; charset=utf-8",
|
|
207
|
+
)
|
|
208
|
+
manifest = ArtifactManifest(
|
|
209
|
+
artifact_id=output_dir.name,
|
|
210
|
+
run_id=run.run_id,
|
|
211
|
+
run_records_sha256=run.records_sha256,
|
|
212
|
+
generated_at=generated_at,
|
|
213
|
+
display_stem=stem,
|
|
214
|
+
artifacts=[html_record],
|
|
215
|
+
)
|
|
216
|
+
manifest_path = write_manifest(output_dir, manifest)
|
|
217
|
+
return TimelineArtifactResult(
|
|
218
|
+
output_dir=str(output_dir),
|
|
219
|
+
html_path=str(output_dir / html_name),
|
|
220
|
+
manifest_path=str(manifest_path),
|
|
221
|
+
manifest=manifest,
|
|
222
|
+
included_events=len(included),
|
|
223
|
+
excluded_old_events=excluded_old,
|
|
224
|
+
excluded_undated_events=excluded_undated,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def events_from_run(run: CIRun) -> list[dict[str, Any]]:
|
|
229
|
+
events = []
|
|
230
|
+
seen = set()
|
|
231
|
+
for snapshot in run.entities:
|
|
232
|
+
entity = snapshot.entity.name
|
|
233
|
+
trials = snapshot.sources.get(SourceName.CLINICAL_TRIALS.value)
|
|
234
|
+
if trials:
|
|
235
|
+
for record in trials.records:
|
|
236
|
+
for field, label in (
|
|
237
|
+
("start_date", "Study start"),
|
|
238
|
+
("primary_completion_date", "Primary completion"),
|
|
239
|
+
("estimated_completion_date", "Study completion"),
|
|
240
|
+
):
|
|
241
|
+
if field in record:
|
|
242
|
+
_append_event(
|
|
243
|
+
events,
|
|
244
|
+
seen,
|
|
245
|
+
source=trials.source.value,
|
|
246
|
+
entity=entity,
|
|
247
|
+
event_type="trial",
|
|
248
|
+
raw_date=record.get(field),
|
|
249
|
+
title=f"{label}: {record.get('title', '')}",
|
|
250
|
+
detail=f"{record.get('phase', '')} · {record.get('status', '')} · {record.get('sponsor', '')}",
|
|
251
|
+
identifier=f"{record.get('nct_id', '')}:{field}",
|
|
252
|
+
source_url=record.get("source_url") or trials.source_url,
|
|
253
|
+
)
|
|
254
|
+
openfda = snapshot.sources.get(SourceName.OPENFDA.value)
|
|
255
|
+
if openfda:
|
|
256
|
+
for record in openfda.records:
|
|
257
|
+
_append_event(
|
|
258
|
+
events,
|
|
259
|
+
seen,
|
|
260
|
+
source=openfda.source.value,
|
|
261
|
+
entity=entity,
|
|
262
|
+
event_type=str(record.get("event_type") or "other"),
|
|
263
|
+
raw_date=record.get("date"),
|
|
264
|
+
title=f"{str(record.get('event_type') or 'Regulatory event').replace('_', ' ').title()}: {record.get('application_number', '')}",
|
|
265
|
+
detail=str(record.get("description", "")),
|
|
266
|
+
identifier=f"{record.get('application_number', '')}:{record.get('submission', '')}",
|
|
267
|
+
source_url=record.get("source_url") or openfda.source_url,
|
|
268
|
+
)
|
|
269
|
+
dailymed = snapshot.sources.get(SourceName.DAILYMED.value)
|
|
270
|
+
if dailymed:
|
|
271
|
+
for record in dailymed.records:
|
|
272
|
+
_append_event(
|
|
273
|
+
events,
|
|
274
|
+
seen,
|
|
275
|
+
source=dailymed.source.value,
|
|
276
|
+
entity=entity,
|
|
277
|
+
event_type="label_change",
|
|
278
|
+
raw_date=record.get("published_date"),
|
|
279
|
+
title=f"Label version {record.get('spl_version', '')}: {record.get('title', '')}",
|
|
280
|
+
detail="DailyMed label history",
|
|
281
|
+
identifier=f"{record.get('set_id', '')}:{record.get('spl_version', '')}",
|
|
282
|
+
source_url=record.get("source_url") or dailymed.source_url,
|
|
283
|
+
)
|
|
284
|
+
pubmed = snapshot.sources.get(SourceName.PUBMED.value)
|
|
285
|
+
if pubmed:
|
|
286
|
+
for record in pubmed.records:
|
|
287
|
+
_append_event(
|
|
288
|
+
events,
|
|
289
|
+
seen,
|
|
290
|
+
source=pubmed.source.value,
|
|
291
|
+
entity=entity,
|
|
292
|
+
event_type="publication",
|
|
293
|
+
raw_date=record.get("pub_date"),
|
|
294
|
+
title=str(record.get("title", "")),
|
|
295
|
+
detail=f"{record.get('journal', '')} · PMID {record.get('pmid', '')}",
|
|
296
|
+
identifier=str(record.get("pmid", "")),
|
|
297
|
+
source_url=record.get("source_url") or pubmed.source_url,
|
|
298
|
+
)
|
|
299
|
+
return events
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _append_event(
|
|
303
|
+
events,
|
|
304
|
+
seen,
|
|
305
|
+
*,
|
|
306
|
+
source,
|
|
307
|
+
entity,
|
|
308
|
+
event_type,
|
|
309
|
+
raw_date,
|
|
310
|
+
title,
|
|
311
|
+
detail,
|
|
312
|
+
identifier,
|
|
313
|
+
source_url,
|
|
314
|
+
):
|
|
315
|
+
key = (source, entity, event_type, str(raw_date or ""), identifier)
|
|
316
|
+
if key in seen:
|
|
317
|
+
return
|
|
318
|
+
seen.add(key)
|
|
319
|
+
events.append(
|
|
320
|
+
{
|
|
321
|
+
"raw_date": raw_date,
|
|
322
|
+
"entity": entity,
|
|
323
|
+
"type": event_type,
|
|
324
|
+
"title": title,
|
|
325
|
+
"detail": detail,
|
|
326
|
+
"id": identifier,
|
|
327
|
+
"source": source,
|
|
328
|
+
"source_url": source_url,
|
|
329
|
+
}
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def normalize_event_date(value: Any) -> tuple[date | None, str]:
|
|
334
|
+
if isinstance(value, datetime):
|
|
335
|
+
return value.date(), "day"
|
|
336
|
+
if isinstance(value, date):
|
|
337
|
+
return value, "day"
|
|
338
|
+
if not isinstance(value, str):
|
|
339
|
+
return None, "unknown"
|
|
340
|
+
text = value.strip()
|
|
341
|
+
for parse_value, pattern, precision in (
|
|
342
|
+
(text, "%Y-%m-%d", "day"),
|
|
343
|
+
(f"{text}-01", "%Y-%m-%d", "month"),
|
|
344
|
+
(f"{text}-01-01", "%Y-%m-%d", "year"),
|
|
345
|
+
(text, "%b %d, %Y", "day"),
|
|
346
|
+
(text, "%B %d, %Y", "day"),
|
|
347
|
+
):
|
|
348
|
+
try:
|
|
349
|
+
return datetime.strptime(parse_value, pattern).date(), precision
|
|
350
|
+
except ValueError:
|
|
351
|
+
continue
|
|
352
|
+
return None, "unknown"
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def calendar_month_cutoff(now: datetime, months_back: int) -> date:
|
|
356
|
+
effective = _utc(now)
|
|
357
|
+
month_index = effective.year * 12 + (effective.month - 1) - months_back
|
|
358
|
+
year, zero_based_month = divmod(month_index, 12)
|
|
359
|
+
month = zero_based_month + 1
|
|
360
|
+
day = min(effective.day, calendar.monthrange(year, month)[1])
|
|
361
|
+
return date(year, month, day)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def render_timeline_html(
|
|
365
|
+
events: list[dict[str, Any]],
|
|
366
|
+
entity_names: list[str],
|
|
367
|
+
coverage: list[dict[str, Any]],
|
|
368
|
+
limitations: list[str],
|
|
369
|
+
*,
|
|
370
|
+
excluded_old: int,
|
|
371
|
+
excluded_undated: int,
|
|
372
|
+
) -> str:
|
|
373
|
+
title = f"CI Timeline: {', '.join(entity_names[:3])}"
|
|
374
|
+
data = json_for_html_script(
|
|
375
|
+
{
|
|
376
|
+
"events": events,
|
|
377
|
+
"coverage": coverage,
|
|
378
|
+
"limitations": limitations,
|
|
379
|
+
"excluded_old": excluded_old,
|
|
380
|
+
"excluded_undated": excluded_undated,
|
|
381
|
+
}
|
|
382
|
+
)
|
|
383
|
+
return f"""<!DOCTYPE html>
|
|
384
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
385
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:; connect-src 'none'; base-uri 'none'; form-action 'none'">
|
|
386
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
387
|
+
<title>{escape(title)}</title>
|
|
388
|
+
<style>body{{font-family:system-ui;background:#f7f9fc;color:#172033;margin:0}}header{{background:#203a5f;color:white;padding:1.5rem}}main{{max-width:900px;margin:2rem auto;padding:0 1rem}}button{{margin:.2rem;padding:.4rem .8rem}}.card{{background:white;border:1px solid #dce3ed;border-left:5px solid #758399;padding:1rem;margin:1rem 0}}.kind-trial{{border-left-color:#2879bd}}.kind-approval{{border-left-color:#27834b}}.kind-label_change{{border-left-color:#b18200}}.kind-publication{{border-left-color:#7b47a6}}.meta{{color:#667085;font-size:.85rem}}.detail{{white-space:pre-wrap}}.coverage{{background:white;border:1px solid #dce3ed;padding:.7rem;margin:.5rem 0}}</style>
|
|
389
|
+
</head><body><header><h1>{escape(title)}</h1><p>{len(events)} included events</p></header>
|
|
390
|
+
<main><section><h2>Coverage and limitations</h2><div id="coverage"></div><ul id="limitations"></ul></section>
|
|
391
|
+
<section><h2>Filters</h2><div id="filters"></div><p id="count"></p><div id="timeline"></div></section></main>
|
|
392
|
+
<script id="timeline-data" type="application/json">{data}</script>
|
|
393
|
+
<script>
|
|
394
|
+
const DATA = JSON.parse(document.getElementById('timeline-data').textContent);
|
|
395
|
+
const KIND_CLASSES = {{trial:'kind-trial',approval:'kind-approval',label_change:'kind-label_change',publication:'kind-publication'}};
|
|
396
|
+
const active = new Set(DATA.events.map(event => event.type));
|
|
397
|
+
function node(tag, className, text) {{
|
|
398
|
+
const value = document.createElement(tag);
|
|
399
|
+
if (className) value.className = className;
|
|
400
|
+
if (text !== undefined) value.textContent = text;
|
|
401
|
+
return value;
|
|
402
|
+
}}
|
|
403
|
+
function renderCoverage() {{
|
|
404
|
+
const rows = DATA.coverage.map(row => node('div','coverage',`${{row.entity}} · ${{row.source}}: ${{row.status}} (${{row.record_count}} returned)`));
|
|
405
|
+
document.getElementById('coverage').replaceChildren(...rows);
|
|
406
|
+
const items = [...DATA.limitations, `Excluded old events: ${{DATA.excluded_old}}`, `Excluded undated events: ${{DATA.excluded_undated}}`]
|
|
407
|
+
.map(value => node('li','',value));
|
|
408
|
+
document.getElementById('limitations').replaceChildren(...items);
|
|
409
|
+
}}
|
|
410
|
+
function render() {{
|
|
411
|
+
const filtered = DATA.events.filter(event => active.has(event.type));
|
|
412
|
+
document.getElementById('count').textContent = `Showing ${{filtered.length}} of ${{DATA.events.length}} events`;
|
|
413
|
+
const cards = filtered.map(event => {{
|
|
414
|
+
const card = node('article','card ' + (KIND_CLASSES[event.type] || ''),undefined);
|
|
415
|
+
card.dataset.type = event.type;
|
|
416
|
+
card.append(node('div','meta',`${{event.date}} · ${{event.date_precision}} · ${{event.source}}`));
|
|
417
|
+
card.append(node('h3','',event.title));
|
|
418
|
+
card.append(node('div','meta',`${{event.entity}} · ${{event.id}}`));
|
|
419
|
+
card.append(node('p','detail',event.detail));
|
|
420
|
+
return card;
|
|
421
|
+
}});
|
|
422
|
+
document.getElementById('timeline').replaceChildren(...cards);
|
|
423
|
+
}}
|
|
424
|
+
function initFilters() {{
|
|
425
|
+
const buttons = [...active].sort().map(type => {{
|
|
426
|
+
const button = node('button','',type.replaceAll('_',' '));
|
|
427
|
+
button.dataset.type = type;
|
|
428
|
+
button.addEventListener('click', () => {{
|
|
429
|
+
if (active.has(type)) active.delete(type); else active.add(type);
|
|
430
|
+
render();
|
|
431
|
+
}});
|
|
432
|
+
return button;
|
|
433
|
+
}});
|
|
434
|
+
document.getElementById('filters').replaceChildren(...buttons);
|
|
435
|
+
}}
|
|
436
|
+
renderCoverage(); initFilters(); render();
|
|
437
|
+
</script></body></html>"""
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _error(code: str, message: str) -> list[dict[str, Any]]:
|
|
441
|
+
return [
|
|
442
|
+
{
|
|
443
|
+
"type": "text",
|
|
444
|
+
"text": json.dumps({"error": {"code": code, "message": message}}, indent=2),
|
|
445
|
+
}
|
|
446
|
+
]
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _utc(value: datetime | None) -> datetime:
|
|
450
|
+
now = value or datetime.now(timezone.utc)
|
|
451
|
+
if now.tzinfo is None:
|
|
452
|
+
raise ValueError("now must be timezone-aware")
|
|
453
|
+
return now.astimezone(timezone.utc)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""ci_track — manage the competitor watchlist."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TrackArgs(BaseModel):
|
|
11
|
+
action: str = Field(description="Action: 'add', 'remove', or 'list'")
|
|
12
|
+
entity_type: str = Field(default="", description="Entity type: 'drug' or 'company' (required for add)")
|
|
13
|
+
name: str = Field(default="", description="Entity name (required for add/remove)")
|
|
14
|
+
therapeutic_area: str = Field(default="", description="Therapeutic area, e.g. 'oncology'")
|
|
15
|
+
aliases: list[str] = Field(default_factory=list, description="Alternative names or codes")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
TOOL: dict[str, Any] = {
|
|
19
|
+
"name": "ci_track",
|
|
20
|
+
"description": (
|
|
21
|
+
"Manage the competitive intelligence watchlist. Add or remove competitor "
|
|
22
|
+
"drugs and companies to track, or list all currently tracked entities. "
|
|
23
|
+
"The watchlist persists across sessions and is used by ci_report to "
|
|
24
|
+
"generate briefings for all tracked competitors."
|
|
25
|
+
),
|
|
26
|
+
"args": TrackArgs,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def handle(arguments: dict[str, Any]) -> list[dict[str, Any]]:
|
|
31
|
+
import json
|
|
32
|
+
|
|
33
|
+
from .._watchlist import WatchlistError, add_entity, load_watchlist, remove_entity
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
return _handle(arguments, add_entity, load_watchlist, remove_entity)
|
|
37
|
+
except WatchlistError as error:
|
|
38
|
+
return [
|
|
39
|
+
{
|
|
40
|
+
"type": "text",
|
|
41
|
+
"text": json.dumps(
|
|
42
|
+
{
|
|
43
|
+
"error": {
|
|
44
|
+
"code": "invalid_watchlist",
|
|
45
|
+
"message": str(error),
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
),
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _handle(arguments, add_entity, load_watchlist, remove_entity):
|
|
54
|
+
import json
|
|
55
|
+
|
|
56
|
+
action = arguments.get("action", "list").lower()
|
|
57
|
+
|
|
58
|
+
if action == "add":
|
|
59
|
+
name = arguments.get("name", "")
|
|
60
|
+
entity_type = arguments.get("entity_type", "")
|
|
61
|
+
if not name or entity_type not in ("drug", "company"):
|
|
62
|
+
return [
|
|
63
|
+
{
|
|
64
|
+
"type": "text",
|
|
65
|
+
"text": json.dumps({"error": "action='add' requires name and entity_type ('drug' or 'company')"}),
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
entities = add_entity(
|
|
69
|
+
entity_type=entity_type,
|
|
70
|
+
name=name,
|
|
71
|
+
therapeutic_area=arguments.get("therapeutic_area", ""),
|
|
72
|
+
aliases=arguments.get("aliases"),
|
|
73
|
+
)
|
|
74
|
+
return [
|
|
75
|
+
{
|
|
76
|
+
"type": "text",
|
|
77
|
+
"text": json.dumps(
|
|
78
|
+
{
|
|
79
|
+
"action": "added",
|
|
80
|
+
"name": name,
|
|
81
|
+
"entity_type": entity_type,
|
|
82
|
+
"watchlist": entities,
|
|
83
|
+
},
|
|
84
|
+
indent=2,
|
|
85
|
+
),
|
|
86
|
+
}
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
if action == "remove":
|
|
90
|
+
name = arguments.get("name", "")
|
|
91
|
+
if not name:
|
|
92
|
+
return [{"type": "text", "text": json.dumps({"error": "action='remove' requires name"})}]
|
|
93
|
+
entities = remove_entity(name)
|
|
94
|
+
return [
|
|
95
|
+
{
|
|
96
|
+
"type": "text",
|
|
97
|
+
"text": json.dumps(
|
|
98
|
+
{
|
|
99
|
+
"action": "removed",
|
|
100
|
+
"name": name,
|
|
101
|
+
"watchlist": entities,
|
|
102
|
+
},
|
|
103
|
+
indent=2,
|
|
104
|
+
),
|
|
105
|
+
}
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
entities = load_watchlist()
|
|
109
|
+
return [
|
|
110
|
+
{
|
|
111
|
+
"type": "text",
|
|
112
|
+
"text": json.dumps(
|
|
113
|
+
{
|
|
114
|
+
"action": "list",
|
|
115
|
+
"total": len(entities),
|
|
116
|
+
"watchlist": entities,
|
|
117
|
+
},
|
|
118
|
+
indent=2,
|
|
119
|
+
),
|
|
120
|
+
}
|
|
121
|
+
]
|