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,247 @@
|
|
|
1
|
+
"""ta_cluster — visit clusters and sequencing (operational mode)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AppointmentArg(BaseModel):
|
|
11
|
+
hcp_id: str = Field(description="HCP with a fixed appointment")
|
|
12
|
+
date: str = Field(description="ISO date, e.g. '2026-08-25'")
|
|
13
|
+
time: str = Field(default="09:00", description="HH:MM, e.g. '10:00'")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ClusterArgs(BaseModel):
|
|
17
|
+
rep_id: str = Field(description="Rep to build a visit plan for")
|
|
18
|
+
period: str = Field(
|
|
19
|
+
default="next_week",
|
|
20
|
+
description="Planning period: 'next_week', 'next_month', or ISO week '2026-W35'",
|
|
21
|
+
)
|
|
22
|
+
appointments: list[AppointmentArg] = Field(
|
|
23
|
+
default_factory=list, description="Fixed appointments to schedule around"
|
|
24
|
+
)
|
|
25
|
+
max_daily_travel_min: int = Field(default=120, description="Max travel minutes per day")
|
|
26
|
+
remote_threshold_min: int = Field(default=60, description="One-way travel above this suggests a remote alternative")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
TOOL: dict[str, Any] = {
|
|
30
|
+
"name": "ta_cluster",
|
|
31
|
+
"description": (
|
|
32
|
+
"Build a visit plan for a rep: cluster their assigned HCPs "
|
|
33
|
+
"geographically, sequence visits within each cluster using a "
|
|
34
|
+
"nearest-neighbor route, and flag remote alternatives where travel "
|
|
35
|
+
"exceeds the threshold. Requires territory data to be loaded and at "
|
|
36
|
+
"least one alignment scenario to exist (uses the latest by default)."
|
|
37
|
+
),
|
|
38
|
+
"args": ClusterArgs,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def handle(arguments: dict[str, Any]) -> list[dict[str, Any]]:
|
|
43
|
+
import json
|
|
44
|
+
|
|
45
|
+
from ..data import get_hcps, get_reps, is_loaded, list_scenarios, load_all, load_scenario
|
|
46
|
+
from ..geo import centroid, grid_cluster, haversine, nearest_neighbor_route, travel_minutes, two_opt_improve
|
|
47
|
+
from ..models import RemoteAlternative, VisitCluster, VisitPlan, VisitStop
|
|
48
|
+
|
|
49
|
+
if not is_loaded():
|
|
50
|
+
load_all()
|
|
51
|
+
|
|
52
|
+
rep_id = arguments["rep_id"]
|
|
53
|
+
period = arguments.get("period", "next_week")
|
|
54
|
+
remote_threshold = arguments.get("remote_threshold_min", 60)
|
|
55
|
+
max_daily_travel = arguments.get("max_daily_travel_min", 120)
|
|
56
|
+
appointments_raw = arguments.get("appointments", [])
|
|
57
|
+
|
|
58
|
+
reps = get_reps()
|
|
59
|
+
rep = next((r for r in reps if r.rep_id == rep_id), None)
|
|
60
|
+
if rep is None:
|
|
61
|
+
return [{"type": "text", "text": json.dumps({"error": f"Rep '{rep_id}' not found."})}]
|
|
62
|
+
|
|
63
|
+
scenarios = list_scenarios()
|
|
64
|
+
if not scenarios:
|
|
65
|
+
return [{"type": "text", "text": json.dumps({"error": "No alignment scenario found. Run ta_align first."})}]
|
|
66
|
+
|
|
67
|
+
latest = load_scenario(scenarios[-1]["name"])
|
|
68
|
+
if latest is None:
|
|
69
|
+
return [{"type": "text", "text": json.dumps({"error": "Could not load latest scenario."})}]
|
|
70
|
+
|
|
71
|
+
rep_hcp_ids = {a["hcp_id"] for a in latest.get("assignments", []) if a["primary_rep"] == rep_id}
|
|
72
|
+
|
|
73
|
+
hcps = get_hcps()
|
|
74
|
+
hcp_map = {h.hcp_id: h for h in hcps}
|
|
75
|
+
rep_hcps = [hcp_map[hid] for hid in rep_hcp_ids if hid in hcp_map]
|
|
76
|
+
|
|
77
|
+
if not rep_hcps:
|
|
78
|
+
return [
|
|
79
|
+
{
|
|
80
|
+
"type": "text",
|
|
81
|
+
"text": json.dumps({"error": f"No HCPs assigned to rep '{rep_id}' in the latest scenario."}),
|
|
82
|
+
}
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
visitable = [h for h in rep_hcps if h.consent_visit]
|
|
86
|
+
|
|
87
|
+
geo_hcps = [(h.lat, h.lng, h.hcp_id) for h in visitable if h.lat is not None and h.lng is not None]
|
|
88
|
+
non_geo = [h for h in visitable if h.lat is None or h.lng is None]
|
|
89
|
+
|
|
90
|
+
appointment_hcp_ids: dict[str, str] = {}
|
|
91
|
+
for appt in appointments_raw:
|
|
92
|
+
hid = appt["hcp_id"] if isinstance(appt, dict) else appt.hcp_id
|
|
93
|
+
date = appt["date"] if isinstance(appt, dict) else appt.date
|
|
94
|
+
appointment_hcp_ids[hid] = date
|
|
95
|
+
|
|
96
|
+
days = list(rep.available_days) if rep.available_days else ["mon", "tue", "wed", "thu", "fri"]
|
|
97
|
+
max_calls = rep.max_daily_calls
|
|
98
|
+
|
|
99
|
+
clusters_out: list[VisitCluster] = []
|
|
100
|
+
sequence_out: list[VisitStop] = []
|
|
101
|
+
remote_out: list[RemoteAlternative] = []
|
|
102
|
+
total_km = 0.0
|
|
103
|
+
|
|
104
|
+
if geo_hcps:
|
|
105
|
+
cluster_ids = grid_cluster(geo_hcps, target_per_cluster=min(max_calls, 6))
|
|
106
|
+
|
|
107
|
+
split_clusters: list[list[str]] = []
|
|
108
|
+
for id_list in cluster_ids:
|
|
109
|
+
while len(id_list) > max_calls:
|
|
110
|
+
split_clusters.append(id_list[:max_calls])
|
|
111
|
+
id_list = id_list[max_calls:]
|
|
112
|
+
split_clusters.append(id_list)
|
|
113
|
+
|
|
114
|
+
appointment_day_map = _map_appointments_to_days(appointment_hcp_ids, days)
|
|
115
|
+
|
|
116
|
+
day_assignments: dict[int, str] = {}
|
|
117
|
+
for ci, id_list in enumerate(split_clusters):
|
|
118
|
+
for hid in id_list:
|
|
119
|
+
if hid in appointment_day_map:
|
|
120
|
+
day_assignments[ci] = appointment_day_map[hid]
|
|
121
|
+
break
|
|
122
|
+
|
|
123
|
+
for ci, id_list in enumerate(split_clusters):
|
|
124
|
+
cluster_hcps = [hcp_map[hid] for hid in id_list if hid in hcp_map]
|
|
125
|
+
points = [(h.lat, h.lng) for h in cluster_hcps if h.lat is not None and h.lng is not None]
|
|
126
|
+
|
|
127
|
+
if not points:
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
start = (rep.base_lat, rep.base_lng)
|
|
131
|
+
order, route_km = nearest_neighbor_route(points, start=start)
|
|
132
|
+
order, route_km = two_opt_improve(points, order)
|
|
133
|
+
|
|
134
|
+
route_min = sum(
|
|
135
|
+
travel_minutes(
|
|
136
|
+
haversine(
|
|
137
|
+
points[order[i]][0],
|
|
138
|
+
points[order[i]][1],
|
|
139
|
+
points[order[i + 1]][0],
|
|
140
|
+
points[order[i + 1]][1],
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
for i in range(len(order) - 1)
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
total_km += route_km
|
|
147
|
+
|
|
148
|
+
c_lat, c_lng = centroid(points)
|
|
149
|
+
cid = f"C{ci + 1:02d}"
|
|
150
|
+
|
|
151
|
+
if ci in day_assignments:
|
|
152
|
+
day = day_assignments[ci]
|
|
153
|
+
else:
|
|
154
|
+
day = days[ci % len(days)]
|
|
155
|
+
|
|
156
|
+
warning = ""
|
|
157
|
+
if route_min > max_daily_travel:
|
|
158
|
+
warning = f"route travel {route_min:.0f} min exceeds {max_daily_travel} min daily limit"
|
|
159
|
+
|
|
160
|
+
clusters_out.append(
|
|
161
|
+
VisitCluster(
|
|
162
|
+
cluster_id=cid,
|
|
163
|
+
hcp_ids=id_list,
|
|
164
|
+
centroid_lat=round(c_lat, 4),
|
|
165
|
+
centroid_lng=round(c_lng, 4),
|
|
166
|
+
estimated_route_km=round(route_km, 1),
|
|
167
|
+
estimated_travel_min=round(route_min, 1),
|
|
168
|
+
hcp_count=len(id_list),
|
|
169
|
+
suggested_day=day,
|
|
170
|
+
travel_warning=warning,
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
prev_point = start
|
|
175
|
+
for visit_i, idx in enumerate(order):
|
|
176
|
+
h = cluster_hcps[idx]
|
|
177
|
+
hop_km = haversine(prev_point[0], prev_point[1], points[idx][0], points[idx][1])
|
|
178
|
+
sequence_out.append(
|
|
179
|
+
VisitStop(
|
|
180
|
+
hcp_id=h.hcp_id,
|
|
181
|
+
hcp_name=h.name,
|
|
182
|
+
visit_order=visit_i + 1,
|
|
183
|
+
cluster_id=cid,
|
|
184
|
+
lat=points[idx][0],
|
|
185
|
+
lng=points[idx][1],
|
|
186
|
+
travel_km_from_previous=round(hop_km, 1),
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
prev_point = points[idx]
|
|
190
|
+
|
|
191
|
+
one_way = travel_minutes(haversine(rep.base_lat, rep.base_lng, h.lat, h.lng))
|
|
192
|
+
if one_way > remote_threshold:
|
|
193
|
+
remote_out.append(
|
|
194
|
+
RemoteAlternative(
|
|
195
|
+
hcp_id=h.hcp_id,
|
|
196
|
+
hcp_name=h.name,
|
|
197
|
+
distance_km=round(haversine(rep.base_lat, rep.base_lng, h.lat, h.lng), 1),
|
|
198
|
+
reason=f"one-way travel {one_way:.0f} min exceeds {remote_threshold} min threshold",
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
for h in non_geo:
|
|
203
|
+
sequence_out.append(
|
|
204
|
+
VisitStop(
|
|
205
|
+
hcp_id=h.hcp_id,
|
|
206
|
+
hcp_name=h.name,
|
|
207
|
+
visit_order=len(sequence_out) + 1,
|
|
208
|
+
cluster_id="UNGEO",
|
|
209
|
+
lat=None,
|
|
210
|
+
lng=None,
|
|
211
|
+
travel_km_from_previous=0.0,
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
plan = VisitPlan(
|
|
216
|
+
rep_id=rep_id,
|
|
217
|
+
rep_name=rep.name,
|
|
218
|
+
period=period,
|
|
219
|
+
clusters=clusters_out,
|
|
220
|
+
visit_sequence=sequence_out,
|
|
221
|
+
remote_alternatives=remote_out,
|
|
222
|
+
total_route_km=round(total_km, 1),
|
|
223
|
+
total_hcps=len(visitable),
|
|
224
|
+
remote_count=len(remote_out),
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
return [{"type": "text", "text": json.dumps(json.loads(plan.model_dump_json()), indent=2)}]
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _map_appointments_to_days(
|
|
231
|
+
appointment_hcp_ids: dict[str, str],
|
|
232
|
+
available_days: list[str],
|
|
233
|
+
) -> dict[str, str]:
|
|
234
|
+
"""Map appointment HCP IDs to a day-of-week string based on the ISO date."""
|
|
235
|
+
import datetime
|
|
236
|
+
|
|
237
|
+
day_names = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
|
238
|
+
result: dict[str, str] = {}
|
|
239
|
+
for hcp_id, date_str in appointment_hcp_ids.items():
|
|
240
|
+
try:
|
|
241
|
+
dt = datetime.date.fromisoformat(date_str)
|
|
242
|
+
dow = day_names[dt.weekday()]
|
|
243
|
+
if dow in available_days:
|
|
244
|
+
result[hcp_id] = dow
|
|
245
|
+
except ValueError:
|
|
246
|
+
continue
|
|
247
|
+
return result
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""ta_compare — side-by-side scenario comparison."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CompareArgs(BaseModel):
|
|
11
|
+
scenarios: list[str] = Field(
|
|
12
|
+
min_length=2,
|
|
13
|
+
max_length=4,
|
|
14
|
+
description="Names of 2-4 saved scenarios to compare",
|
|
15
|
+
)
|
|
16
|
+
focus: str = Field(
|
|
17
|
+
default="",
|
|
18
|
+
description="Objective to highlight: workload, travel, disruption, or coverage",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
TOOL: dict[str, Any] = {
|
|
23
|
+
"name": "ta_compare",
|
|
24
|
+
"description": (
|
|
25
|
+
"Compare 2-4 saved territory alignment scenarios side by side. "
|
|
26
|
+
"Returns an objectives matrix, Pareto analysis, per-HCP movement "
|
|
27
|
+
"summary, and a plain-language trade-off narrative. Use after "
|
|
28
|
+
"generating multiple scenarios with ta_align."
|
|
29
|
+
),
|
|
30
|
+
"args": CompareArgs,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def handle(arguments: dict[str, Any]) -> list[dict[str, Any]]:
|
|
35
|
+
import json
|
|
36
|
+
|
|
37
|
+
from ..data import load_scenario
|
|
38
|
+
|
|
39
|
+
names = arguments["scenarios"]
|
|
40
|
+
focus = arguments.get("focus", "")
|
|
41
|
+
|
|
42
|
+
scenarios: dict[str, dict[str, Any]] = {}
|
|
43
|
+
for name in names:
|
|
44
|
+
data = load_scenario(name)
|
|
45
|
+
if data is None:
|
|
46
|
+
return [
|
|
47
|
+
{"type": "text", "text": json.dumps({"error": f"Scenario '{name}' not found. Run ta_align first."})}
|
|
48
|
+
]
|
|
49
|
+
scenarios[name] = data
|
|
50
|
+
|
|
51
|
+
comparison_table = _build_comparison_table(scenarios)
|
|
52
|
+
pareto = _pareto_analysis(scenarios)
|
|
53
|
+
movements = _movement_summary(scenarios)
|
|
54
|
+
narrative = _build_narrative(scenarios, comparison_table, pareto, focus)
|
|
55
|
+
|
|
56
|
+
result = {
|
|
57
|
+
"comparison_table": comparison_table,
|
|
58
|
+
"pareto": pareto,
|
|
59
|
+
"movement_summary": movements,
|
|
60
|
+
"trade_off_narrative": narrative,
|
|
61
|
+
}
|
|
62
|
+
return [{"type": "text", "text": json.dumps(result, indent=2)}]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _build_comparison_table(
|
|
66
|
+
scenarios: dict[str, dict[str, Any]],
|
|
67
|
+
) -> dict[str, dict[str, float]]:
|
|
68
|
+
table: dict[str, dict[str, float]] = {}
|
|
69
|
+
for name, data in scenarios.items():
|
|
70
|
+
obj = data.get("objectives", {})
|
|
71
|
+
table[name] = {
|
|
72
|
+
"workload_balance": obj.get("workload_balance", 0),
|
|
73
|
+
"travel_efficiency": obj.get("travel_efficiency", 0),
|
|
74
|
+
"disruption": obj.get("disruption", 0),
|
|
75
|
+
"coverage": obj.get("coverage", 0),
|
|
76
|
+
"composite": obj.get("composite", 0),
|
|
77
|
+
}
|
|
78
|
+
return table
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _pareto_analysis(
|
|
82
|
+
scenarios: dict[str, dict[str, Any]],
|
|
83
|
+
) -> dict[str, Any]:
|
|
84
|
+
names = list(scenarios.keys())
|
|
85
|
+
objectives_keys = ["workload_balance", "travel_efficiency", "disruption", "coverage"]
|
|
86
|
+
|
|
87
|
+
scores: dict[str, list[float]] = {}
|
|
88
|
+
for name, data in scenarios.items():
|
|
89
|
+
obj = data.get("objectives", {})
|
|
90
|
+
scores[name] = [obj.get(k, 0) for k in objectives_keys]
|
|
91
|
+
|
|
92
|
+
dominated: set[str] = set()
|
|
93
|
+
for i, n1 in enumerate(names):
|
|
94
|
+
for n2 in names[i + 1 :]:
|
|
95
|
+
s1, s2 = scores[n1], scores[n2]
|
|
96
|
+
if all(a <= b for a, b in zip(s1, s2)) and any(a < b for a, b in zip(s1, s2)):
|
|
97
|
+
dominated.add(n2)
|
|
98
|
+
elif all(a <= b for a, b in zip(s2, s1)) and any(a < b for a, b in zip(s2, s1)):
|
|
99
|
+
dominated.add(n1)
|
|
100
|
+
|
|
101
|
+
optimal = [n for n in names if n not in dominated]
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
"pareto_optimal": optimal,
|
|
105
|
+
"dominated": list(dominated),
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _movement_summary(
|
|
110
|
+
scenarios: dict[str, dict[str, Any]],
|
|
111
|
+
) -> dict[str, Any]:
|
|
112
|
+
names = list(scenarios.keys())
|
|
113
|
+
if len(names) < 2:
|
|
114
|
+
return {}
|
|
115
|
+
|
|
116
|
+
rep_maps: dict[str, dict[str, str]] = {}
|
|
117
|
+
for name, data in scenarios.items():
|
|
118
|
+
rep_maps[name] = {a["hcp_id"]: a["primary_rep"] for a in data.get("assignments", [])}
|
|
119
|
+
|
|
120
|
+
pairwise: dict[str, dict[str, int]] = {}
|
|
121
|
+
for i, n1 in enumerate(names):
|
|
122
|
+
for n2 in names[i + 1 :]:
|
|
123
|
+
m1, m2 = rep_maps[n1], rep_maps[n2]
|
|
124
|
+
common = set(m1.keys()) & set(m2.keys())
|
|
125
|
+
moved = sum(1 for h in common if m1[h] != m2[h])
|
|
126
|
+
key = f"{n1}_vs_{n2}"
|
|
127
|
+
pairwise[key] = {
|
|
128
|
+
"moved": moved,
|
|
129
|
+
"total": len(common),
|
|
130
|
+
"pct_moved": round(moved / max(len(common), 1) * 100, 1),
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return pairwise
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _build_narrative(
|
|
137
|
+
scenarios: dict[str, dict[str, Any]],
|
|
138
|
+
table: dict[str, dict[str, float]],
|
|
139
|
+
pareto: dict[str, Any],
|
|
140
|
+
focus: str,
|
|
141
|
+
) -> str:
|
|
142
|
+
names = list(table.keys())
|
|
143
|
+
if len(names) < 2:
|
|
144
|
+
return "Need at least 2 scenarios to compare."
|
|
145
|
+
|
|
146
|
+
parts: list[str] = []
|
|
147
|
+
|
|
148
|
+
optimal = pareto.get("pareto_optimal", [])
|
|
149
|
+
if optimal:
|
|
150
|
+
parts.append(f"Pareto-optimal scenario(s): {', '.join(optimal)}.")
|
|
151
|
+
|
|
152
|
+
dominated = pareto.get("dominated", [])
|
|
153
|
+
if dominated:
|
|
154
|
+
parts.append(f"Dominated (worse on all objectives): {', '.join(dominated)}.")
|
|
155
|
+
|
|
156
|
+
best_composite = min(names, key=lambda n: table[n]["composite"])
|
|
157
|
+
parts.append(f"Lowest composite score: {best_composite} ({table[best_composite]['composite']:.3f}).")
|
|
158
|
+
|
|
159
|
+
if focus and focus in ("workload", "travel", "disruption", "coverage"):
|
|
160
|
+
key_map = {
|
|
161
|
+
"workload": "workload_balance",
|
|
162
|
+
"travel": "travel_efficiency",
|
|
163
|
+
"disruption": "disruption",
|
|
164
|
+
"coverage": "coverage",
|
|
165
|
+
}
|
|
166
|
+
fk = key_map[focus]
|
|
167
|
+
best = min(names, key=lambda n: table[n][fk])
|
|
168
|
+
worst = max(names, key=lambda n: table[n][fk])
|
|
169
|
+
parts.append(
|
|
170
|
+
f"Focus '{focus}': best = {best} ({table[best][fk]:.3f}), worst = {worst} ({table[worst][fk]:.3f})."
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
return " ".join(parts)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""ta_evaluate — detailed scoring breakdown for a saved scenario."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class EvaluateArgs(BaseModel):
|
|
11
|
+
scenario_name: str = Field(description="Name of a saved scenario to evaluate")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
TOOL: dict[str, Any] = {
|
|
15
|
+
"name": "ta_evaluate",
|
|
16
|
+
"description": (
|
|
17
|
+
"Evaluate a saved territory alignment scenario in detail. Returns "
|
|
18
|
+
"objective scores, per-rep territory breakdown (HCP count, potential, "
|
|
19
|
+
"workload, travel, segment coverage), relationship continuity stats, "
|
|
20
|
+
"and workload distribution analysis. Call ta_align first to create a scenario."
|
|
21
|
+
),
|
|
22
|
+
"args": EvaluateArgs,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def handle(arguments: dict[str, Any]) -> list[dict[str, Any]]:
|
|
27
|
+
import json
|
|
28
|
+
|
|
29
|
+
from ..data import get_hcps, is_loaded, load_all, load_scenario
|
|
30
|
+
|
|
31
|
+
name = arguments["scenario_name"]
|
|
32
|
+
scenario = load_scenario(name)
|
|
33
|
+
|
|
34
|
+
if scenario is None:
|
|
35
|
+
return [
|
|
36
|
+
{
|
|
37
|
+
"type": "text",
|
|
38
|
+
"text": json.dumps({"error": f"Scenario '{name}' not found. Run ta_align first or check ta_status."}),
|
|
39
|
+
}
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
if not is_loaded():
|
|
43
|
+
load_all()
|
|
44
|
+
|
|
45
|
+
objectives = scenario.get("objectives", {})
|
|
46
|
+
territories = scenario.get("territory_summary", [])
|
|
47
|
+
assignments = scenario.get("assignments", [])
|
|
48
|
+
unassigned = scenario.get("unassigned", [])
|
|
49
|
+
|
|
50
|
+
workloads = [t["workload_hours_weekly"] for t in territories]
|
|
51
|
+
travel_hours = [t["travel_hours_weekly"] for t in territories]
|
|
52
|
+
|
|
53
|
+
kept = sum(t.get("relationships_kept", 0) for t in territories)
|
|
54
|
+
new = sum(t.get("relationships_new", 0) for t in territories)
|
|
55
|
+
total_rel = kept + new
|
|
56
|
+
|
|
57
|
+
evaluation = {
|
|
58
|
+
"scenario_name": name,
|
|
59
|
+
"objectives": objectives,
|
|
60
|
+
"territory_details": territories,
|
|
61
|
+
"coverage_analysis": _coverage_analysis(assignments, unassigned, get_hcps()),
|
|
62
|
+
"relationship_continuity": {
|
|
63
|
+
"kept": kept,
|
|
64
|
+
"new": new,
|
|
65
|
+
"pct_kept": round(kept / max(total_rel, 1) * 100, 1),
|
|
66
|
+
},
|
|
67
|
+
"workload_distribution": {
|
|
68
|
+
"min": round(min(workloads), 1) if workloads else 0,
|
|
69
|
+
"max": round(max(workloads), 1) if workloads else 0,
|
|
70
|
+
"median": round(sorted(workloads)[len(workloads) // 2], 1) if workloads else 0,
|
|
71
|
+
"std_dev": round(_std(workloads), 2) if workloads else 0,
|
|
72
|
+
},
|
|
73
|
+
"travel_distribution": {
|
|
74
|
+
"min_hours": round(min(travel_hours), 2) if travel_hours else 0,
|
|
75
|
+
"max_hours": round(max(travel_hours), 2) if travel_hours else 0,
|
|
76
|
+
"median_hours": round(sorted(travel_hours)[len(travel_hours) // 2], 2) if travel_hours else 0,
|
|
77
|
+
},
|
|
78
|
+
"unassigned": unassigned,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return [{"type": "text", "text": json.dumps(evaluation, indent=2)}]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _coverage_analysis(
|
|
85
|
+
assignments: list[dict],
|
|
86
|
+
unassigned: list[dict],
|
|
87
|
+
all_hcps: list[Any],
|
|
88
|
+
) -> dict[str, Any]:
|
|
89
|
+
from ..models import HCP
|
|
90
|
+
|
|
91
|
+
segment_totals: dict[str, int] = {}
|
|
92
|
+
for h in all_hcps:
|
|
93
|
+
seg = h.segment if isinstance(h, HCP) else "unknown"
|
|
94
|
+
segment_totals[seg] = segment_totals.get(seg, 0) + 1
|
|
95
|
+
|
|
96
|
+
segment_covered: dict[str, int] = {}
|
|
97
|
+
for a in assignments:
|
|
98
|
+
seg = a.get("segment", "unknown")
|
|
99
|
+
segment_covered[seg] = segment_covered.get(seg, 0) + 1
|
|
100
|
+
|
|
101
|
+
result: dict[str, Any] = {}
|
|
102
|
+
for seg in sorted(segment_totals.keys()):
|
|
103
|
+
total = segment_totals[seg]
|
|
104
|
+
covered = segment_covered.get(seg, 0)
|
|
105
|
+
result[seg] = {
|
|
106
|
+
"total": total,
|
|
107
|
+
"covered": covered,
|
|
108
|
+
"unassigned": total - covered,
|
|
109
|
+
"pct": round(covered / max(total, 1) * 100, 1),
|
|
110
|
+
}
|
|
111
|
+
return result
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _std(values: list[float]) -> float:
|
|
115
|
+
if len(values) < 2:
|
|
116
|
+
return 0.0
|
|
117
|
+
mean = sum(values) / len(values)
|
|
118
|
+
variance = sum((v - mean) ** 2 for v in values) / len(values)
|
|
119
|
+
return variance**0.5
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""ta_status — data overview and scenario inventory."""
|
|
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": "ta_status",
|
|
16
|
+
"description": (
|
|
17
|
+
"Show the current state of loaded territory data: HCP and rep counts, "
|
|
18
|
+
"segment distribution, geocoding coverage, unassigned HCPs, and a list "
|
|
19
|
+
"of saved scenarios with timestamps. Call this first to confirm data is "
|
|
20
|
+
"loaded before running ta_align or ta_cluster."
|
|
21
|
+
),
|
|
22
|
+
"args": StatusArgs,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def handle(arguments: dict[str, Any]) -> list[dict[str, Any]]:
|
|
27
|
+
import json
|
|
28
|
+
|
|
29
|
+
from ..data import get_summary, is_loaded, load_all
|
|
30
|
+
|
|
31
|
+
if not is_loaded():
|
|
32
|
+
load_all()
|
|
33
|
+
|
|
34
|
+
return [{"type": "text", "text": json.dumps(get_summary(), indent=2)}]
|