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,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
+ ]