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,504 @@
|
|
|
1
|
+
"""ta_visualize — interactive HTML map of territory alignment scenarios."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class VisualizeArgs(BaseModel):
|
|
11
|
+
scenarios: list[str] = Field(
|
|
12
|
+
min_length=1,
|
|
13
|
+
max_length=2,
|
|
14
|
+
description="1-2 scenario names. Single = territory map; two = before/after comparison",
|
|
15
|
+
)
|
|
16
|
+
show_movements: bool = Field(
|
|
17
|
+
default=True,
|
|
18
|
+
description="Draw arrows for HCPs that changed rep between scenarios",
|
|
19
|
+
)
|
|
20
|
+
show_rep_bases: bool = Field(
|
|
21
|
+
default=True,
|
|
22
|
+
description="Show rep home-base locations as distinct markers",
|
|
23
|
+
)
|
|
24
|
+
file_name: str | None = Field(
|
|
25
|
+
default=None,
|
|
26
|
+
description="Output filename stem (auto-generated if omitted)",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
TOOL: dict[str, Any] = {
|
|
31
|
+
"name": "ta_visualize",
|
|
32
|
+
"description": (
|
|
33
|
+
"Generate an interactive HTML map visualising one or two territory "
|
|
34
|
+
"alignment scenarios. Shows HCPs as colour-coded markers grouped by "
|
|
35
|
+
"assigned rep, rep home-base locations, and (for two-scenario comparison) "
|
|
36
|
+
"movement arrows for reassigned HCPs with a toggle switch. Opens in any "
|
|
37
|
+
"browser. Call ta_align first to create scenarios."
|
|
38
|
+
),
|
|
39
|
+
"args": VisualizeArgs,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
_REP_COLORS = [
|
|
43
|
+
"#2196F3",
|
|
44
|
+
"#4CAF50",
|
|
45
|
+
"#FF9800",
|
|
46
|
+
"#9C27B0",
|
|
47
|
+
"#F44336",
|
|
48
|
+
"#00BCD4",
|
|
49
|
+
"#795548",
|
|
50
|
+
"#607D8B",
|
|
51
|
+
"#E91E63",
|
|
52
|
+
"#8BC34A",
|
|
53
|
+
"#FF5722",
|
|
54
|
+
"#3F51B5",
|
|
55
|
+
"#009688",
|
|
56
|
+
"#FFC107",
|
|
57
|
+
"#673AB7",
|
|
58
|
+
"#CDDC39",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _sanitize_filename(name: str) -> str:
|
|
63
|
+
import re as _re
|
|
64
|
+
|
|
65
|
+
clean = _re.sub(r"[^a-zA-Z0-9_\-]", "_", name)
|
|
66
|
+
return clean[:100] if clean else "output"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def handle(arguments: dict[str, Any]) -> list[dict[str, Any]]:
|
|
70
|
+
import json
|
|
71
|
+
|
|
72
|
+
from ..data import _scenarios_dir, get_hcps, get_reps, is_loaded, load_all, load_scenario
|
|
73
|
+
|
|
74
|
+
if not is_loaded():
|
|
75
|
+
load_all()
|
|
76
|
+
|
|
77
|
+
scenario_names = arguments["scenarios"]
|
|
78
|
+
show_movements = arguments.get("show_movements", True)
|
|
79
|
+
show_rep_bases = arguments.get("show_rep_bases", True)
|
|
80
|
+
file_name = arguments.get("file_name")
|
|
81
|
+
|
|
82
|
+
scenarios: dict[str, dict[str, Any]] = {}
|
|
83
|
+
for name in scenario_names:
|
|
84
|
+
data = load_scenario(name)
|
|
85
|
+
if data is None:
|
|
86
|
+
return [
|
|
87
|
+
{
|
|
88
|
+
"type": "text",
|
|
89
|
+
"text": json.dumps({"error": f"Scenario '{name}' not found. Run ta_align first."}),
|
|
90
|
+
}
|
|
91
|
+
]
|
|
92
|
+
scenarios[name] = data
|
|
93
|
+
|
|
94
|
+
hcps = get_hcps()
|
|
95
|
+
hcp_map = {h.hcp_id: h for h in hcps}
|
|
96
|
+
reps = get_reps()
|
|
97
|
+
rep_map = {r.rep_id: r for r in reps}
|
|
98
|
+
|
|
99
|
+
all_rep_ids = set()
|
|
100
|
+
for sc in scenarios.values():
|
|
101
|
+
for a in sc.get("assignments", []):
|
|
102
|
+
all_rep_ids.add(a["primary_rep"])
|
|
103
|
+
for r in reps:
|
|
104
|
+
all_rep_ids.add(r.rep_id)
|
|
105
|
+
|
|
106
|
+
color_map = {}
|
|
107
|
+
for i, rid in enumerate(sorted(all_rep_ids)):
|
|
108
|
+
color_map[rid] = _REP_COLORS[i % len(_REP_COLORS)]
|
|
109
|
+
|
|
110
|
+
is_comparison = len(scenario_names) == 2
|
|
111
|
+
|
|
112
|
+
map_data = _build_map_data(
|
|
113
|
+
scenarios,
|
|
114
|
+
scenario_names,
|
|
115
|
+
hcp_map,
|
|
116
|
+
rep_map,
|
|
117
|
+
color_map,
|
|
118
|
+
show_movements and is_comparison,
|
|
119
|
+
show_rep_bases,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
if not file_name:
|
|
123
|
+
file_name = "_vs_".join(scenario_names)
|
|
124
|
+
|
|
125
|
+
html = _render_html(map_data, scenario_names, is_comparison, color_map)
|
|
126
|
+
|
|
127
|
+
from shared.filesystem import atomic_write_text, ensure_private_dir
|
|
128
|
+
|
|
129
|
+
out_dir = ensure_private_dir(_scenarios_dir().parent)
|
|
130
|
+
file_name = _sanitize_filename(file_name)
|
|
131
|
+
html_path = out_dir / f"{file_name}.html"
|
|
132
|
+
atomic_write_text(html_path, html)
|
|
133
|
+
|
|
134
|
+
result = {
|
|
135
|
+
"success": True,
|
|
136
|
+
"html_path": str(html_path),
|
|
137
|
+
"scenarios": scenario_names,
|
|
138
|
+
"comparison": is_comparison,
|
|
139
|
+
"hcp_count": len(hcp_map),
|
|
140
|
+
"rep_count": len(all_rep_ids),
|
|
141
|
+
}
|
|
142
|
+
return [{"type": "text", "text": json.dumps(result, indent=2)}]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _build_map_data(
|
|
146
|
+
scenarios: dict[str, dict[str, Any]],
|
|
147
|
+
names: list[str],
|
|
148
|
+
hcp_map: dict,
|
|
149
|
+
rep_map: dict,
|
|
150
|
+
color_map: dict[str, str],
|
|
151
|
+
show_movements: bool,
|
|
152
|
+
show_rep_bases: bool,
|
|
153
|
+
) -> dict[str, Any]:
|
|
154
|
+
scenario_layers: dict[str, Any] = {}
|
|
155
|
+
|
|
156
|
+
for name in names:
|
|
157
|
+
sc = scenarios[name]
|
|
158
|
+
markers = []
|
|
159
|
+
for a in sc.get("assignments", []):
|
|
160
|
+
hcp = hcp_map.get(a["hcp_id"])
|
|
161
|
+
if not hcp or hcp.lat is None or hcp.lng is None:
|
|
162
|
+
continue
|
|
163
|
+
segment = a.get("segment", "medium")
|
|
164
|
+
radius = 10 if segment == "high" else 7 if segment == "medium" else 5
|
|
165
|
+
markers.append(
|
|
166
|
+
{
|
|
167
|
+
"lat": hcp.lat,
|
|
168
|
+
"lng": hcp.lng,
|
|
169
|
+
"hcp_id": a["hcp_id"],
|
|
170
|
+
"hcp_name": a.get("hcp_name", hcp.name),
|
|
171
|
+
"rep_id": a["primary_rep"],
|
|
172
|
+
"color": color_map.get(a["primary_rep"], "#999"),
|
|
173
|
+
"segment": segment,
|
|
174
|
+
"tier": a.get("tier", 0),
|
|
175
|
+
"radius": radius,
|
|
176
|
+
"travel_min": a.get("estimated_travel_min", 0),
|
|
177
|
+
"is_changed": a.get("is_changed", False),
|
|
178
|
+
"previous_rep": a.get("previous_rep", ""),
|
|
179
|
+
}
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
unassigned = []
|
|
183
|
+
for u in sc.get("unassigned", []):
|
|
184
|
+
hcp = hcp_map.get(u["hcp_id"])
|
|
185
|
+
if hcp and hcp.lat is not None and hcp.lng is not None:
|
|
186
|
+
unassigned.append(
|
|
187
|
+
{
|
|
188
|
+
"lat": hcp.lat,
|
|
189
|
+
"lng": hcp.lng,
|
|
190
|
+
"hcp_id": u["hcp_id"],
|
|
191
|
+
"hcp_name": hcp.name,
|
|
192
|
+
"reason": u.get("reason", ""),
|
|
193
|
+
}
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
objectives = sc.get("objectives", {})
|
|
197
|
+
scenario_layers[name] = {
|
|
198
|
+
"markers": markers,
|
|
199
|
+
"unassigned": unassigned,
|
|
200
|
+
"objectives": objectives,
|
|
201
|
+
"assignment_count": len(sc.get("assignments", [])),
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
rep_bases = []
|
|
205
|
+
if show_rep_bases:
|
|
206
|
+
for r in rep_map.values():
|
|
207
|
+
if r.base_lat != 0 or r.base_lng != 0:
|
|
208
|
+
rep_bases.append(
|
|
209
|
+
{
|
|
210
|
+
"lat": r.base_lat,
|
|
211
|
+
"lng": r.base_lng,
|
|
212
|
+
"rep_id": r.rep_id,
|
|
213
|
+
"rep_name": r.name,
|
|
214
|
+
"color": color_map.get(r.rep_id, "#999"),
|
|
215
|
+
}
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
movements = []
|
|
219
|
+
if show_movements and len(names) == 2:
|
|
220
|
+
sc_a = scenarios[names[0]]
|
|
221
|
+
sc_b = scenarios[names[1]]
|
|
222
|
+
map_a = {a["hcp_id"]: a["primary_rep"] for a in sc_a.get("assignments", [])}
|
|
223
|
+
map_b = {a["hcp_id"]: a["primary_rep"] for a in sc_b.get("assignments", [])}
|
|
224
|
+
|
|
225
|
+
for hcp_id in set(map_a.keys()) & set(map_b.keys()):
|
|
226
|
+
if map_a[hcp_id] != map_b[hcp_id]:
|
|
227
|
+
hcp = hcp_map.get(hcp_id)
|
|
228
|
+
if not hcp or hcp.lat is None or hcp.lng is None:
|
|
229
|
+
continue
|
|
230
|
+
old_rep = rep_map.get(map_a[hcp_id])
|
|
231
|
+
new_rep = rep_map.get(map_b[hcp_id])
|
|
232
|
+
if not old_rep or not new_rep:
|
|
233
|
+
continue
|
|
234
|
+
movements.append(
|
|
235
|
+
{
|
|
236
|
+
"hcp_id": hcp_id,
|
|
237
|
+
"hcp_name": hcp.name,
|
|
238
|
+
"lat": hcp.lat,
|
|
239
|
+
"lng": hcp.lng,
|
|
240
|
+
"old_rep_id": map_a[hcp_id],
|
|
241
|
+
"new_rep_id": map_b[hcp_id],
|
|
242
|
+
"old_color": color_map.get(map_a[hcp_id], "#999"),
|
|
243
|
+
"new_color": color_map.get(map_b[hcp_id], "#999"),
|
|
244
|
+
}
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
"scenario_layers": scenario_layers,
|
|
249
|
+
"rep_bases": rep_bases,
|
|
250
|
+
"movements": movements,
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _render_html(
|
|
255
|
+
map_data: dict[str, Any],
|
|
256
|
+
scenario_names: list[str],
|
|
257
|
+
is_comparison: bool,
|
|
258
|
+
color_map: dict[str, str],
|
|
259
|
+
) -> str:
|
|
260
|
+
from html import escape
|
|
261
|
+
|
|
262
|
+
from shared.filesystem import json_for_html_script
|
|
263
|
+
|
|
264
|
+
data_json = json_for_html_script(map_data)
|
|
265
|
+
names_json = json_for_html_script(scenario_names)
|
|
266
|
+
colors_json = json_for_html_script(color_map)
|
|
267
|
+
title = " vs ".join(scenario_names) if is_comparison else scenario_names[0]
|
|
268
|
+
|
|
269
|
+
return f"""<!DOCTYPE html>
|
|
270
|
+
<html lang="en">
|
|
271
|
+
<head>
|
|
272
|
+
<meta charset="utf-8">
|
|
273
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
274
|
+
<title>Territory Alignment: {escape(title)}</title>
|
|
275
|
+
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
|
276
|
+
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
|
|
277
|
+
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
|
278
|
+
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
|
279
|
+
<style>
|
|
280
|
+
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|
281
|
+
body {{ font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }}
|
|
282
|
+
#map {{ width: 100%; height: 100vh; }}
|
|
283
|
+
.panel {{
|
|
284
|
+
position: absolute; top: 10px; right: 10px; z-index: 1000;
|
|
285
|
+
background: rgba(255,255,255,0.95); border-radius: 8px;
|
|
286
|
+
padding: 16px; max-width: 320px; box-shadow: 0 2px 12px rgba(0,0,0,0.15);
|
|
287
|
+
font-size: 13px; max-height: 90vh; overflow-y: auto;
|
|
288
|
+
}}
|
|
289
|
+
.panel h2 {{ font-size: 15px; color: #1a365d; margin-bottom: 8px; border-bottom: 2px solid #2c5282; padding-bottom: 4px; }}
|
|
290
|
+
.panel h3 {{ font-size: 13px; color: #2c5282; margin-top: 10px; margin-bottom: 4px; }}
|
|
291
|
+
.legend-item {{ display: flex; align-items: center; gap: 6px; margin: 3px 0; }}
|
|
292
|
+
.legend-dot {{ width: 14px; height: 14px; border-radius: 50%; flex-shrink: 0; border: 2px solid rgba(0,0,0,0.2); }}
|
|
293
|
+
.legend-base {{ width: 14px; height: 14px; flex-shrink: 0; border: 2px solid rgba(0,0,0,0.3); transform: rotate(45deg); }}
|
|
294
|
+
.stats {{ margin-top: 8px; }}
|
|
295
|
+
.stats td {{ padding: 2px 8px 2px 0; }}
|
|
296
|
+
.stats td:last-child {{ font-weight: 600; }}
|
|
297
|
+
.toggle-group {{ margin: 10px 0; display: flex; gap: 0; }}
|
|
298
|
+
.toggle-btn {{
|
|
299
|
+
flex: 1; padding: 6px 10px; border: 1px solid #2c5282; background: #fff;
|
|
300
|
+
color: #2c5282; cursor: pointer; font-size: 12px; font-weight: 600;
|
|
301
|
+
transition: all 0.15s;
|
|
302
|
+
}}
|
|
303
|
+
.toggle-btn:first-child {{ border-radius: 4px 0 0 4px; }}
|
|
304
|
+
.toggle-btn:last-child {{ border-radius: 0 4px 4px 0; }}
|
|
305
|
+
.toggle-btn.active {{ background: #2c5282; color: #fff; }}
|
|
306
|
+
.movement-toggle {{ margin: 8px 0; }}
|
|
307
|
+
.movement-toggle label {{ cursor: pointer; font-size: 12px; }}
|
|
308
|
+
.delta {{ font-size: 11px; margin-left: 4px; }}
|
|
309
|
+
.delta.better {{ color: #38a169; }}
|
|
310
|
+
.delta.worse {{ color: #e53e3e; }}
|
|
311
|
+
.segment-key {{ display: flex; gap: 12px; margin-top: 6px; font-size: 11px; color: #718096; }}
|
|
312
|
+
.segment-key span {{ display: flex; align-items: center; gap: 3px; }}
|
|
313
|
+
.seg-dot {{ border-radius: 50%; background: #a0aec0; display: inline-block; }}
|
|
314
|
+
</style>
|
|
315
|
+
</head>
|
|
316
|
+
<body>
|
|
317
|
+
<div id="map"></div>
|
|
318
|
+
<div class="panel" id="panel"></div>
|
|
319
|
+
|
|
320
|
+
<script>
|
|
321
|
+
const MAP_DATA = {data_json};
|
|
322
|
+
const SCENARIO_NAMES = {names_json};
|
|
323
|
+
const COLOR_MAP = {colors_json};
|
|
324
|
+
const IS_COMPARISON = {"true" if is_comparison else "false"};
|
|
325
|
+
|
|
326
|
+
const map = L.map('map');
|
|
327
|
+
L.tileLayer('https://{{s}}.basemaps.cartocdn.com/light_all/{{z}}/{{x}}/{{y}}@2x.png', {{
|
|
328
|
+
attribution: '© OpenStreetMap © CARTO',
|
|
329
|
+
maxZoom: 18
|
|
330
|
+
}}).addTo(map);
|
|
331
|
+
|
|
332
|
+
let currentScenario = SCENARIO_NAMES[0];
|
|
333
|
+
let hcpLayers = {{}};
|
|
334
|
+
let baseLayers = L.layerGroup().addTo(map);
|
|
335
|
+
let movementLayer = L.layerGroup();
|
|
336
|
+
let showMovements = true;
|
|
337
|
+
|
|
338
|
+
function buildPanel() {{
|
|
339
|
+
const panel = document.getElementById('panel');
|
|
340
|
+
let html = '<h2>Territory Alignment</h2>';
|
|
341
|
+
|
|
342
|
+
if (IS_COMPARISON) {{
|
|
343
|
+
html += '<div class="toggle-group">';
|
|
344
|
+
SCENARIO_NAMES.forEach(name => {{
|
|
345
|
+
const cls = name === currentScenario ? 'active' : '';
|
|
346
|
+
html += `<button class="toggle-btn ${{cls}}" onclick="switchScenario('${{name}}')">${{name}}</button>`;
|
|
347
|
+
}});
|
|
348
|
+
html += '</div>';
|
|
349
|
+
html += '<div class="movement-toggle"><label><input type="checkbox" id="mvToggle" ' +
|
|
350
|
+
(showMovements ? 'checked' : '') + ' onchange="toggleMovements()"> Show movements</label></div>';
|
|
351
|
+
}}
|
|
352
|
+
|
|
353
|
+
const sc = MAP_DATA.scenario_layers[currentScenario];
|
|
354
|
+
const obj = sc.objectives;
|
|
355
|
+
html += '<h3>Scores</h3><table class="stats">';
|
|
356
|
+
html += `<tr><td>Composite</td><td>${{(obj.composite || 0).toFixed(3)}}</td></tr>`;
|
|
357
|
+
html += `<tr><td>Workload</td><td>${{(obj.workload_balance || 0).toFixed(3)}}</td></tr>`;
|
|
358
|
+
html += `<tr><td>Travel</td><td>${{(obj.travel_efficiency || 0).toFixed(3)}}</td></tr>`;
|
|
359
|
+
html += `<tr><td>Disruption</td><td>${{(obj.disruption || 0).toFixed(3)}}</td></tr>`;
|
|
360
|
+
html += `<tr><td>Coverage</td><td>${{(obj.coverage || 0).toFixed(3)}}</td></tr>`;
|
|
361
|
+
html += `<tr><td>Assigned</td><td>${{sc.assignment_count}}</td></tr>`;
|
|
362
|
+
html += `<tr><td>Unassigned</td><td>${{sc.unassigned.length}}</td></tr>`;
|
|
363
|
+
html += '</table>';
|
|
364
|
+
|
|
365
|
+
if (IS_COMPARISON) {{
|
|
366
|
+
const other = SCENARIO_NAMES.find(n => n !== currentScenario);
|
|
367
|
+
const scOther = MAP_DATA.scenario_layers[other];
|
|
368
|
+
const moved = MAP_DATA.movements.length;
|
|
369
|
+
html += `<h3>Changes vs ${{other}}</h3>`;
|
|
370
|
+
html += `<table class="stats"><tr><td>HCPs moved</td><td>${{moved}}</td></tr></table>`;
|
|
371
|
+
}}
|
|
372
|
+
|
|
373
|
+
html += '<h3>Rep Legend</h3>';
|
|
374
|
+
const repCounts = {{}};
|
|
375
|
+
sc.markers.forEach(m => {{ repCounts[m.rep_id] = (repCounts[m.rep_id] || 0) + 1; }});
|
|
376
|
+
Object.keys(COLOR_MAP).sort().forEach(rid => {{
|
|
377
|
+
const c = repCounts[rid] || 0;
|
|
378
|
+
if (c > 0 || MAP_DATA.rep_bases.some(b => b.rep_id === rid)) {{
|
|
379
|
+
const repBase = MAP_DATA.rep_bases.find(b => b.rep_id === rid);
|
|
380
|
+
const label = repBase ? `${{rid}} (${{repBase.rep_name}})` : rid;
|
|
381
|
+
html += `<div class="legend-item"><div class="legend-dot" style="background:${{COLOR_MAP[rid]}}"></div>${{label}} <span style="color:#718096">(${{c}})</span></div>`;
|
|
382
|
+
}}
|
|
383
|
+
}});
|
|
384
|
+
|
|
385
|
+
html += '<h3>Segment Size</h3>';
|
|
386
|
+
html += '<div class="segment-key">';
|
|
387
|
+
html += '<span><span class="seg-dot" style="width:10px;height:10px;"></span> high</span>';
|
|
388
|
+
html += '<span><span class="seg-dot" style="width:7px;height:7px;"></span> medium</span>';
|
|
389
|
+
html += '<span><span class="seg-dot" style="width:5px;height:5px;"></span> low</span>';
|
|
390
|
+
html += '</div>';
|
|
391
|
+
|
|
392
|
+
panel.innerHTML = html;
|
|
393
|
+
}}
|
|
394
|
+
|
|
395
|
+
function drawMarkers() {{
|
|
396
|
+
Object.values(hcpLayers).forEach(lg => lg.remove());
|
|
397
|
+
hcpLayers = {{}};
|
|
398
|
+
|
|
399
|
+
const sc = MAP_DATA.scenario_layers[currentScenario];
|
|
400
|
+
const lg = L.layerGroup().addTo(map);
|
|
401
|
+
|
|
402
|
+
sc.markers.forEach(m => {{
|
|
403
|
+
const movedHcp = IS_COMPARISON && MAP_DATA.movements.find(mv => mv.hcp_id === m.hcp_id);
|
|
404
|
+
const opts = {{
|
|
405
|
+
radius: m.radius,
|
|
406
|
+
fillColor: m.color,
|
|
407
|
+
color: movedHcp ? '#e53e3e' : 'rgba(0,0,0,0.3)',
|
|
408
|
+
weight: movedHcp ? 3 : 1.5,
|
|
409
|
+
fillOpacity: 0.8,
|
|
410
|
+
dashArray: movedHcp ? '4 3' : null,
|
|
411
|
+
}};
|
|
412
|
+
const circle = L.circleMarker([m.lat, m.lng], opts);
|
|
413
|
+
let popup = `<b>${{m.hcp_name}}</b><br>ID: ${{m.hcp_id}}<br>Rep: ${{m.rep_id}}<br>` +
|
|
414
|
+
`Segment: ${{m.segment}} · Tier: ${{m.tier}}<br>Travel: ${{m.travel_min}} min`;
|
|
415
|
+
if (movedHcp) {{
|
|
416
|
+
popup += `<br><span style="color:#e53e3e;font-weight:600">Moved from ${{movedHcp.old_rep_id}} → ${{movedHcp.new_rep_id}}</span>`;
|
|
417
|
+
}}
|
|
418
|
+
circle.bindPopup(popup);
|
|
419
|
+
circle.addTo(lg);
|
|
420
|
+
}});
|
|
421
|
+
|
|
422
|
+
sc.unassigned.forEach(u => {{
|
|
423
|
+
const circle = L.circleMarker([u.lat, u.lng], {{
|
|
424
|
+
radius: 6, fillColor: '#e53e3e', color: '#fff', weight: 2, fillOpacity: 0.9,
|
|
425
|
+
}});
|
|
426
|
+
circle.bindPopup(`<b>${{u.hcp_name}}</b><br>ID: ${{u.hcp_id}}<br><span style="color:#e53e3e">Unassigned: ${{u.reason}}</span>`);
|
|
427
|
+
circle.addTo(lg);
|
|
428
|
+
}});
|
|
429
|
+
|
|
430
|
+
hcpLayers[currentScenario] = lg;
|
|
431
|
+
}}
|
|
432
|
+
|
|
433
|
+
function drawBases() {{
|
|
434
|
+
baseLayers.clearLayers();
|
|
435
|
+
MAP_DATA.rep_bases.forEach(b => {{
|
|
436
|
+
const icon = L.divIcon({{
|
|
437
|
+
className: '',
|
|
438
|
+
html: `<div style="width:18px;height:18px;background:${{b.color}};border:3px solid rgba(0,0,0,0.3);` +
|
|
439
|
+
`transform:rotate(45deg);box-shadow:0 1px 4px rgba(0,0,0,0.3);"></div>`,
|
|
440
|
+
iconSize: [18, 18],
|
|
441
|
+
iconAnchor: [9, 9],
|
|
442
|
+
}});
|
|
443
|
+
const marker = L.marker([b.lat, b.lng], {{ icon }});
|
|
444
|
+
marker.bindPopup(`<b>${{b.rep_name}}</b><br>ID: ${{b.rep_id}}<br>Home base`);
|
|
445
|
+
marker.addTo(baseLayers);
|
|
446
|
+
}});
|
|
447
|
+
}}
|
|
448
|
+
|
|
449
|
+
function drawMovements() {{
|
|
450
|
+
movementLayer.clearLayers();
|
|
451
|
+
if (!showMovements || !IS_COMPARISON) return;
|
|
452
|
+
|
|
453
|
+
MAP_DATA.movements.forEach(mv => {{
|
|
454
|
+
const oldBase = MAP_DATA.rep_bases.find(b => b.rep_id === mv.old_rep_id);
|
|
455
|
+
const newBase = MAP_DATA.rep_bases.find(b => b.rep_id === mv.new_rep_id);
|
|
456
|
+
|
|
457
|
+
if (oldBase && newBase) {{
|
|
458
|
+
L.polyline([[oldBase.lat, oldBase.lng], [mv.lat, mv.lng]], {{
|
|
459
|
+
color: mv.old_color, weight: 1.5, opacity: 0.35, dashArray: '4 4'
|
|
460
|
+
}}).bindPopup(
|
|
461
|
+
`<b>${{mv.hcp_name}}</b><br>${{mv.old_rep_id}} → ${{mv.new_rep_id}}`
|
|
462
|
+
).addTo(movementLayer);
|
|
463
|
+
L.polyline([[mv.lat, mv.lng], [newBase.lat, newBase.lng]], {{
|
|
464
|
+
color: mv.new_color, weight: 2, opacity: 0.6
|
|
465
|
+
}}).addTo(movementLayer);
|
|
466
|
+
}}
|
|
467
|
+
}});
|
|
468
|
+
|
|
469
|
+
movementLayer.addTo(map);
|
|
470
|
+
}}
|
|
471
|
+
|
|
472
|
+
function switchScenario(name) {{
|
|
473
|
+
currentScenario = name;
|
|
474
|
+
drawMarkers();
|
|
475
|
+
drawMovements();
|
|
476
|
+
buildPanel();
|
|
477
|
+
}}
|
|
478
|
+
|
|
479
|
+
function toggleMovements() {{
|
|
480
|
+
showMovements = document.getElementById('mvToggle').checked;
|
|
481
|
+
if (showMovements) {{
|
|
482
|
+
drawMovements();
|
|
483
|
+
}} else {{
|
|
484
|
+
movementLayer.clearLayers();
|
|
485
|
+
movementLayer.remove();
|
|
486
|
+
}}
|
|
487
|
+
}}
|
|
488
|
+
|
|
489
|
+
// Init
|
|
490
|
+
drawMarkers();
|
|
491
|
+
drawBases();
|
|
492
|
+
drawMovements();
|
|
493
|
+
buildPanel();
|
|
494
|
+
|
|
495
|
+
// Fit bounds
|
|
496
|
+
const allPoints = [];
|
|
497
|
+
MAP_DATA.scenario_layers[SCENARIO_NAMES[0]].markers.forEach(m => allPoints.push([m.lat, m.lng]));
|
|
498
|
+
MAP_DATA.rep_bases.forEach(b => allPoints.push([b.lat, b.lng]));
|
|
499
|
+
if (allPoints.length > 0) {{
|
|
500
|
+
map.fitBounds(L.latLngBounds(allPoints).pad(0.1));
|
|
501
|
+
}}
|
|
502
|
+
</script>
|
|
503
|
+
</body>
|
|
504
|
+
</html>"""
|
shared/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""`shared` — reusable library for open-pharma-plugins capabilities.
|
|
2
|
+
|
|
3
|
+
A plain library at the src/ root (peer to mcp_framework), importable by any
|
|
4
|
+
capability from source AND the wheel — it is NOT a server/capability (no __main__, no
|
|
5
|
+
tools, no console entry). Submodules:
|
|
6
|
+
|
|
7
|
+
- shared.env config + get_env (the single call-time entry for reading env vars)
|
|
8
|
+
|
|
9
|
+
Reuse these across capabilities instead of importing a sibling capability's server package
|
|
10
|
+
(that only resolves once installed, and couples the servers together).
|
|
11
|
+
"""
|