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
|
+
"""Assignment solver: greedy seed + swap-based local search."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .geo import haversine, travel_minutes
|
|
8
|
+
from .models import (
|
|
9
|
+
HCP,
|
|
10
|
+
AssignmentResult,
|
|
11
|
+
Constraint,
|
|
12
|
+
CurrentAssignment,
|
|
13
|
+
ObjectiveWeights,
|
|
14
|
+
Override,
|
|
15
|
+
Rep,
|
|
16
|
+
ScenarioResult,
|
|
17
|
+
TerritorySummary,
|
|
18
|
+
UnassignedHCP,
|
|
19
|
+
)
|
|
20
|
+
from .scoring import score_alignment
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def solve(
|
|
24
|
+
hcps: list[HCP],
|
|
25
|
+
reps: list[Rep],
|
|
26
|
+
current_alignment: list[CurrentAssignment],
|
|
27
|
+
constraints: list[Constraint],
|
|
28
|
+
weights: ObjectiveWeights,
|
|
29
|
+
overrides: list[Override] | None = None,
|
|
30
|
+
lock_reps: list[str] | None = None,
|
|
31
|
+
scenario_name: str = "unnamed",
|
|
32
|
+
max_iterations: int = 2000,
|
|
33
|
+
) -> ScenarioResult:
|
|
34
|
+
"""Run greedy assignment + local search optimisation."""
|
|
35
|
+
hcp_map = {h.hcp_id: h for h in hcps}
|
|
36
|
+
rep_map = {r.rep_id: r for r in reps}
|
|
37
|
+
current_map = {a.hcp_id: a.primary_rep for a in current_alignment}
|
|
38
|
+
override_map = {o.hcp_id: o.rep_id for o in (overrides or [])}
|
|
39
|
+
locked = set(lock_reps or [])
|
|
40
|
+
max_per_rep = _get_max_per_rep(constraints)
|
|
41
|
+
account_groups = _get_account_groups(constraints, hcps)
|
|
42
|
+
|
|
43
|
+
locked_hcps: set[str] = set()
|
|
44
|
+
for hcp_id, rep_id in current_map.items():
|
|
45
|
+
if rep_id in locked:
|
|
46
|
+
locked_hcps.add(hcp_id)
|
|
47
|
+
for hcp_id in override_map:
|
|
48
|
+
locked_hcps.add(hcp_id)
|
|
49
|
+
|
|
50
|
+
assignment: dict[str, str] = {}
|
|
51
|
+
rep_counts: dict[str, int] = {r.rep_id: 0 for r in reps}
|
|
52
|
+
rep_workloads: dict[str, float] = {r.rep_id: 0.0 for r in reps}
|
|
53
|
+
unassigned: list[UnassignedHCP] = []
|
|
54
|
+
|
|
55
|
+
for hcp_id, rep_id in override_map.items():
|
|
56
|
+
if hcp_id in hcp_map and rep_id in rep_map:
|
|
57
|
+
assignment[hcp_id] = rep_id
|
|
58
|
+
rep_counts[rep_id] = rep_counts.get(rep_id, 0) + 1
|
|
59
|
+
rep_workloads[rep_id] = rep_workloads.get(rep_id, 0.0) + _hcp_weekly_hours(hcp_map[hcp_id])
|
|
60
|
+
|
|
61
|
+
for hcp_id in locked_hcps:
|
|
62
|
+
if hcp_id not in assignment and hcp_id in current_map:
|
|
63
|
+
rep_id = current_map[hcp_id]
|
|
64
|
+
if rep_id in rep_map:
|
|
65
|
+
assignment[hcp_id] = rep_id
|
|
66
|
+
rep_counts[rep_id] = rep_counts.get(rep_id, 0) + 1
|
|
67
|
+
rep_workloads[rep_id] = rep_workloads.get(rep_id, 0.0) + _hcp_weekly_hours(hcp_map[hcp_id])
|
|
68
|
+
|
|
69
|
+
_assign_account_groups(
|
|
70
|
+
account_groups,
|
|
71
|
+
hcps,
|
|
72
|
+
reps,
|
|
73
|
+
assignment,
|
|
74
|
+
rep_counts,
|
|
75
|
+
rep_workloads,
|
|
76
|
+
constraints,
|
|
77
|
+
current_map,
|
|
78
|
+
max_per_rep,
|
|
79
|
+
rep_map,
|
|
80
|
+
locked_hcps,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
remaining = [h for h in hcps if h.hcp_id not in assignment]
|
|
84
|
+
remaining.sort(key=lambda h: h.annual_potential, reverse=True)
|
|
85
|
+
|
|
86
|
+
for hcp in remaining:
|
|
87
|
+
best_rep = _best_eligible_rep(
|
|
88
|
+
hcp,
|
|
89
|
+
reps,
|
|
90
|
+
rep_counts,
|
|
91
|
+
rep_workloads,
|
|
92
|
+
constraints,
|
|
93
|
+
current_map,
|
|
94
|
+
max_per_rep,
|
|
95
|
+
rep_map,
|
|
96
|
+
)
|
|
97
|
+
if best_rep:
|
|
98
|
+
assignment[hcp.hcp_id] = best_rep
|
|
99
|
+
rep_counts[best_rep] = rep_counts.get(best_rep, 0) + 1
|
|
100
|
+
rep_workloads[best_rep] = rep_workloads.get(best_rep, 0.0) + _hcp_weekly_hours(hcp)
|
|
101
|
+
else:
|
|
102
|
+
unassigned.append(
|
|
103
|
+
UnassignedHCP(
|
|
104
|
+
hcp_id=hcp.hcp_id,
|
|
105
|
+
reason=_unassign_reason(hcp, reps, rep_counts, constraints, max_per_rep, rep_map),
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
iterations_used = _local_search(
|
|
110
|
+
assignment,
|
|
111
|
+
hcps,
|
|
112
|
+
reps,
|
|
113
|
+
constraints,
|
|
114
|
+
weights,
|
|
115
|
+
current_map,
|
|
116
|
+
locked_hcps,
|
|
117
|
+
max_per_rep,
|
|
118
|
+
hcp_map,
|
|
119
|
+
rep_map,
|
|
120
|
+
max_iterations,
|
|
121
|
+
account_groups,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
assignments = _build_assignments(assignment, hcp_map, rep_map, current_map)
|
|
125
|
+
objectives = score_alignment(assignments, hcps, reps, weights)
|
|
126
|
+
territory = _build_territory_summary(assignments, hcp_map, rep_map, current_map)
|
|
127
|
+
|
|
128
|
+
return ScenarioResult(
|
|
129
|
+
scenario_name=scenario_name,
|
|
130
|
+
assignments=assignments,
|
|
131
|
+
territory_summary=territory,
|
|
132
|
+
objectives=objectives,
|
|
133
|
+
unassigned=unassigned,
|
|
134
|
+
weights_used=weights,
|
|
135
|
+
metadata={
|
|
136
|
+
"solver": "greedy_local_search",
|
|
137
|
+
"iterations": iterations_used,
|
|
138
|
+
},
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# -- greedy helpers --
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _hcp_weekly_hours(hcp: HCP) -> float:
|
|
146
|
+
hours_per_visit = 1.0 if hcp.segment == "high" else 0.75 if hcp.segment == "medium" else 0.5
|
|
147
|
+
visits_per_year = 24 if hcp.segment == "high" else 12 if hcp.segment == "medium" else 6
|
|
148
|
+
return (hours_per_visit * visits_per_year) / 48.0
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _best_eligible_rep(
|
|
152
|
+
hcp: HCP,
|
|
153
|
+
reps: list[Rep],
|
|
154
|
+
rep_counts: dict[str, int],
|
|
155
|
+
rep_workloads: dict[str, float],
|
|
156
|
+
constraints: list[Constraint],
|
|
157
|
+
current_map: dict[str, str],
|
|
158
|
+
max_per_rep: int,
|
|
159
|
+
rep_map: dict[str, Rep],
|
|
160
|
+
) -> str | None:
|
|
161
|
+
"""Find the best eligible rep for an HCP."""
|
|
162
|
+
best_score = float("inf")
|
|
163
|
+
best_rep = None
|
|
164
|
+
hcp_hours = _hcp_weekly_hours(hcp)
|
|
165
|
+
|
|
166
|
+
for rep in reps:
|
|
167
|
+
if not _is_eligible(hcp, rep, constraints):
|
|
168
|
+
continue
|
|
169
|
+
if rep_counts.get(rep.rep_id, 0) >= max_per_rep:
|
|
170
|
+
continue
|
|
171
|
+
if rep_workloads.get(rep.rep_id, 0.0) + hcp_hours > rep.max_weekly_hours:
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
travel = 0.0
|
|
175
|
+
if hcp.lat is not None and hcp.lng is not None:
|
|
176
|
+
travel = haversine(rep.base_lat, rep.base_lng, hcp.lat, hcp.lng)
|
|
177
|
+
|
|
178
|
+
disruption = 0.0 if current_map.get(hcp.hcp_id) == rep.rep_id else 1.0
|
|
179
|
+
|
|
180
|
+
score = travel * 0.5 + disruption * 50.0
|
|
181
|
+
if score < best_score:
|
|
182
|
+
best_score = score
|
|
183
|
+
best_rep = rep.rep_id
|
|
184
|
+
|
|
185
|
+
return best_rep
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _is_eligible(hcp: HCP, rep: Rep, constraints: list[Constraint]) -> bool:
|
|
189
|
+
"""Check if rep meets hard constraints for this HCP."""
|
|
190
|
+
for c in constraints:
|
|
191
|
+
if c.type == "product_match" and c.value == "required":
|
|
192
|
+
if hcp.product_requirements:
|
|
193
|
+
if not any(p in rep.product_expertise for p in hcp.product_requirements):
|
|
194
|
+
return False
|
|
195
|
+
return True
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _unassign_reason(
|
|
199
|
+
hcp: HCP,
|
|
200
|
+
reps: list[Rep],
|
|
201
|
+
rep_counts: dict[str, int],
|
|
202
|
+
constraints: list[Constraint],
|
|
203
|
+
max_per_rep: int,
|
|
204
|
+
rep_map: dict[str, Rep],
|
|
205
|
+
) -> str:
|
|
206
|
+
eligible = [r for r in reps if _is_eligible(hcp, r, constraints)]
|
|
207
|
+
if not eligible:
|
|
208
|
+
return "no eligible rep has required product expertise"
|
|
209
|
+
hcp_hours = _hcp_weekly_hours(hcp)
|
|
210
|
+
for r in eligible:
|
|
211
|
+
if rep_counts.get(r.rep_id, 0) < max_per_rep:
|
|
212
|
+
rep = rep_map.get(r.rep_id)
|
|
213
|
+
if rep and rep.max_weekly_hours >= hcp_hours:
|
|
214
|
+
return "unknown"
|
|
215
|
+
at_capacity = all(rep_counts.get(r.rep_id, 0) >= max_per_rep for r in eligible)
|
|
216
|
+
if at_capacity:
|
|
217
|
+
return "all eligible reps at capacity"
|
|
218
|
+
return "all eligible reps would exceed weekly hour limit"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _get_max_per_rep(constraints: list[Constraint]) -> int:
|
|
222
|
+
for c in constraints:
|
|
223
|
+
if c.type == "max_hcps_per_rep" and c.scope == "global":
|
|
224
|
+
try:
|
|
225
|
+
return int(c.value)
|
|
226
|
+
except (ValueError, TypeError):
|
|
227
|
+
pass
|
|
228
|
+
return 200
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _get_account_groups(
|
|
232
|
+
constraints: list[Constraint],
|
|
233
|
+
hcps: list[HCP],
|
|
234
|
+
) -> dict[str, list[str]]:
|
|
235
|
+
"""Return {account_id: [hcp_ids]} for accounts with a same_primary_rep constraint."""
|
|
236
|
+
constrained_accounts: set[str] = set()
|
|
237
|
+
for c in constraints:
|
|
238
|
+
if c.type == "account_grouping" and c.value == "same_primary_rep":
|
|
239
|
+
parts = c.scope.split(":", 1)
|
|
240
|
+
if len(parts) == 2 and parts[0] == "account":
|
|
241
|
+
constrained_accounts.add(parts[1])
|
|
242
|
+
|
|
243
|
+
groups: dict[str, list[str]] = {}
|
|
244
|
+
for h in hcps:
|
|
245
|
+
if h.account_id in constrained_accounts:
|
|
246
|
+
groups.setdefault(h.account_id, []).append(h.hcp_id)
|
|
247
|
+
return groups
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _assign_account_groups(
|
|
251
|
+
account_groups: dict[str, list[str]],
|
|
252
|
+
hcps: list[HCP],
|
|
253
|
+
reps: list[Rep],
|
|
254
|
+
assignment: dict[str, str],
|
|
255
|
+
rep_counts: dict[str, int],
|
|
256
|
+
rep_workloads: dict[str, float],
|
|
257
|
+
constraints: list[Constraint],
|
|
258
|
+
current_map: dict[str, str],
|
|
259
|
+
max_per_rep: int,
|
|
260
|
+
rep_map: dict[str, Rep],
|
|
261
|
+
locked_hcps: set[str],
|
|
262
|
+
) -> None:
|
|
263
|
+
"""Assign account-grouped HCPs to a single rep per account."""
|
|
264
|
+
hcp_map = {h.hcp_id: h for h in hcps}
|
|
265
|
+
for account_id, hcp_ids in account_groups.items():
|
|
266
|
+
unassigned_ids = [hid for hid in hcp_ids if hid not in assignment]
|
|
267
|
+
if not unassigned_ids:
|
|
268
|
+
continue
|
|
269
|
+
|
|
270
|
+
already_assigned_rep = None
|
|
271
|
+
for hid in hcp_ids:
|
|
272
|
+
if hid in assignment:
|
|
273
|
+
already_assigned_rep = assignment[hid]
|
|
274
|
+
break
|
|
275
|
+
|
|
276
|
+
group_hcps = [hcp_map[hid] for hid in unassigned_ids if hid in hcp_map]
|
|
277
|
+
if not group_hcps:
|
|
278
|
+
continue
|
|
279
|
+
|
|
280
|
+
group_hours = sum(_hcp_weekly_hours(h) for h in group_hcps)
|
|
281
|
+
|
|
282
|
+
if already_assigned_rep and already_assigned_rep in rep_map:
|
|
283
|
+
rep = rep_map[already_assigned_rep]
|
|
284
|
+
all_eligible = all(_is_eligible(h, rep, constraints) for h in group_hcps)
|
|
285
|
+
fits_count = rep_counts.get(already_assigned_rep, 0) + len(group_hcps) <= max_per_rep
|
|
286
|
+
fits_hours = rep_workloads.get(already_assigned_rep, 0.0) + group_hours <= rep.max_weekly_hours
|
|
287
|
+
if all_eligible and fits_count and fits_hours:
|
|
288
|
+
for h in group_hcps:
|
|
289
|
+
assignment[h.hcp_id] = already_assigned_rep
|
|
290
|
+
rep_counts[already_assigned_rep] = rep_counts.get(already_assigned_rep, 0) + 1
|
|
291
|
+
rep_workloads[already_assigned_rep] = rep_workloads.get(
|
|
292
|
+
already_assigned_rep, 0.0
|
|
293
|
+
) + _hcp_weekly_hours(h)
|
|
294
|
+
continue
|
|
295
|
+
|
|
296
|
+
best_rep = None
|
|
297
|
+
best_score = float("inf")
|
|
298
|
+
for rep in reps:
|
|
299
|
+
all_eligible = all(_is_eligible(h, rep, constraints) for h in group_hcps)
|
|
300
|
+
if not all_eligible:
|
|
301
|
+
continue
|
|
302
|
+
if rep_counts.get(rep.rep_id, 0) + len(group_hcps) > max_per_rep:
|
|
303
|
+
continue
|
|
304
|
+
if rep_workloads.get(rep.rep_id, 0.0) + group_hours > rep.max_weekly_hours:
|
|
305
|
+
continue
|
|
306
|
+
|
|
307
|
+
travel = 0.0
|
|
308
|
+
geo_count = 0
|
|
309
|
+
for h in group_hcps:
|
|
310
|
+
if h.lat is not None and h.lng is not None:
|
|
311
|
+
travel += haversine(rep.base_lat, rep.base_lng, h.lat, h.lng)
|
|
312
|
+
geo_count += 1
|
|
313
|
+
avg_travel = travel / max(geo_count, 1)
|
|
314
|
+
|
|
315
|
+
disruption = sum(1 for h in group_hcps if current_map.get(h.hcp_id) != rep.rep_id)
|
|
316
|
+
score = avg_travel * 0.5 + disruption * 50.0
|
|
317
|
+
if score < best_score:
|
|
318
|
+
best_score = score
|
|
319
|
+
best_rep = rep.rep_id
|
|
320
|
+
|
|
321
|
+
if best_rep:
|
|
322
|
+
for h in group_hcps:
|
|
323
|
+
assignment[h.hcp_id] = best_rep
|
|
324
|
+
rep_counts[best_rep] = rep_counts.get(best_rep, 0) + 1
|
|
325
|
+
rep_workloads[best_rep] = rep_workloads.get(best_rep, 0.0) + _hcp_weekly_hours(h)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# -- local search --
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _local_search(
|
|
332
|
+
assignment: dict[str, str],
|
|
333
|
+
hcps: list[HCP],
|
|
334
|
+
reps: list[Rep],
|
|
335
|
+
constraints: list[Constraint],
|
|
336
|
+
weights: ObjectiveWeights,
|
|
337
|
+
current_map: dict[str, str],
|
|
338
|
+
locked_hcps: set[str],
|
|
339
|
+
max_per_rep: int,
|
|
340
|
+
hcp_map: dict[str, HCP],
|
|
341
|
+
rep_map: dict[str, Rep],
|
|
342
|
+
max_iterations: int,
|
|
343
|
+
account_groups: dict[str, list[str]] | None = None,
|
|
344
|
+
) -> int:
|
|
345
|
+
"""Improve assignment via pairwise swaps. Returns iterations used."""
|
|
346
|
+
grouped_hcps: set[str] = set()
|
|
347
|
+
for ids in (account_groups or {}).values():
|
|
348
|
+
grouped_hcps.update(ids)
|
|
349
|
+
|
|
350
|
+
movable = [
|
|
351
|
+
h for h in hcps if h.hcp_id in assignment and h.hcp_id not in locked_hcps and h.hcp_id not in grouped_hcps
|
|
352
|
+
]
|
|
353
|
+
|
|
354
|
+
current_assignments = _build_assignments(assignment, hcp_map, rep_map, current_map)
|
|
355
|
+
current_score = score_alignment(current_assignments, hcps, reps, weights)
|
|
356
|
+
best_composite = current_score.composite
|
|
357
|
+
|
|
358
|
+
for iteration in range(max_iterations):
|
|
359
|
+
improved = False
|
|
360
|
+
|
|
361
|
+
for i, h1 in enumerate(movable):
|
|
362
|
+
for h2 in movable[i + 1 :]:
|
|
363
|
+
r1 = assignment[h1.hcp_id]
|
|
364
|
+
r2 = assignment[h2.hcp_id]
|
|
365
|
+
if r1 == r2:
|
|
366
|
+
continue
|
|
367
|
+
|
|
368
|
+
rep1 = rep_map.get(r1)
|
|
369
|
+
rep2 = rep_map.get(r2)
|
|
370
|
+
if not rep1 or not rep2:
|
|
371
|
+
continue
|
|
372
|
+
if not _is_eligible(h1, rep2, constraints):
|
|
373
|
+
continue
|
|
374
|
+
if not _is_eligible(h2, rep1, constraints):
|
|
375
|
+
continue
|
|
376
|
+
|
|
377
|
+
assignment[h1.hcp_id] = r2
|
|
378
|
+
assignment[h2.hcp_id] = r1
|
|
379
|
+
|
|
380
|
+
trial = _build_assignments(assignment, hcp_map, rep_map, current_map)
|
|
381
|
+
trial_score = score_alignment(trial, hcps, reps, weights)
|
|
382
|
+
|
|
383
|
+
if trial_score.composite < best_composite - 0.0005:
|
|
384
|
+
best_composite = trial_score.composite
|
|
385
|
+
improved = True
|
|
386
|
+
else:
|
|
387
|
+
assignment[h1.hcp_id] = r1
|
|
388
|
+
assignment[h2.hcp_id] = r2
|
|
389
|
+
|
|
390
|
+
if not improved:
|
|
391
|
+
return iteration + 1
|
|
392
|
+
|
|
393
|
+
return max_iterations
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
# -- output builders --
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _build_assignments(
|
|
400
|
+
assignment: dict[str, str],
|
|
401
|
+
hcp_map: dict[str, HCP],
|
|
402
|
+
rep_map: dict[str, Rep],
|
|
403
|
+
current_map: dict[str, str],
|
|
404
|
+
) -> list[AssignmentResult]:
|
|
405
|
+
results: list[AssignmentResult] = []
|
|
406
|
+
for hcp_id, rep_id in sorted(assignment.items()):
|
|
407
|
+
hcp = hcp_map.get(hcp_id)
|
|
408
|
+
rep = rep_map.get(rep_id)
|
|
409
|
+
if not hcp or not rep:
|
|
410
|
+
continue
|
|
411
|
+
|
|
412
|
+
prev_rep = current_map.get(hcp_id, "")
|
|
413
|
+
is_changed = prev_rep != rep_id and prev_rep != ""
|
|
414
|
+
|
|
415
|
+
travel = 0.0
|
|
416
|
+
if hcp.lat is not None and hcp.lng is not None:
|
|
417
|
+
travel = travel_minutes(haversine(rep.base_lat, rep.base_lng, hcp.lat, hcp.lng))
|
|
418
|
+
|
|
419
|
+
visits = 24 if hcp.segment == "high" else 12 if hcp.segment == "medium" else 6
|
|
420
|
+
|
|
421
|
+
data: dict[str, Any] = {
|
|
422
|
+
"hcp_id": hcp_id,
|
|
423
|
+
"hcp_name": hcp.name,
|
|
424
|
+
"primary_rep": rep_id,
|
|
425
|
+
"previous_rep": prev_rep,
|
|
426
|
+
"is_changed": is_changed,
|
|
427
|
+
"change_reason": "optimiser_reassigned" if is_changed else "",
|
|
428
|
+
"estimated_travel_min": round(travel, 1),
|
|
429
|
+
"estimated_annual_visits": visits,
|
|
430
|
+
"segment": hcp.segment,
|
|
431
|
+
"tier": hcp.tier,
|
|
432
|
+
}
|
|
433
|
+
data.update(hcp.model_extra)
|
|
434
|
+
results.append(AssignmentResult.model_validate(data))
|
|
435
|
+
|
|
436
|
+
return results
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _build_territory_summary(
|
|
440
|
+
assignments: list[AssignmentResult],
|
|
441
|
+
hcp_map: dict[str, HCP],
|
|
442
|
+
rep_map: dict[str, Rep],
|
|
443
|
+
current_map: dict[str, str],
|
|
444
|
+
) -> list[TerritorySummary]:
|
|
445
|
+
rep_data: dict[str, dict[str, Any]] = {}
|
|
446
|
+
|
|
447
|
+
for a in assignments:
|
|
448
|
+
hcp = hcp_map.get(a.hcp_id)
|
|
449
|
+
if not hcp:
|
|
450
|
+
continue
|
|
451
|
+
rd = rep_data.setdefault(
|
|
452
|
+
a.primary_rep,
|
|
453
|
+
{
|
|
454
|
+
"hcp_count": 0,
|
|
455
|
+
"total_potential": 0.0,
|
|
456
|
+
"workload": 0.0,
|
|
457
|
+
"travel": 0.0,
|
|
458
|
+
"seg_high": 0,
|
|
459
|
+
"seg_med": 0,
|
|
460
|
+
"seg_low": 0,
|
|
461
|
+
"kept": 0,
|
|
462
|
+
"new": 0,
|
|
463
|
+
},
|
|
464
|
+
)
|
|
465
|
+
rd["hcp_count"] += 1
|
|
466
|
+
rd["total_potential"] += hcp.annual_potential
|
|
467
|
+
|
|
468
|
+
hours_per = 1.0 if hcp.segment == "high" else 0.75 if hcp.segment == "medium" else 0.5
|
|
469
|
+
visits = 24 if hcp.segment == "high" else 12 if hcp.segment == "medium" else 6
|
|
470
|
+
rd["workload"] += (hours_per * visits) / 48.0
|
|
471
|
+
rd["travel"] += a.estimated_travel_min / 60.0 * (visits / 48.0)
|
|
472
|
+
|
|
473
|
+
if hcp.segment == "high":
|
|
474
|
+
rd["seg_high"] += 1
|
|
475
|
+
elif hcp.segment == "medium":
|
|
476
|
+
rd["seg_med"] += 1
|
|
477
|
+
else:
|
|
478
|
+
rd["seg_low"] += 1
|
|
479
|
+
|
|
480
|
+
if current_map.get(a.hcp_id) == a.primary_rep:
|
|
481
|
+
rd["kept"] += 1
|
|
482
|
+
else:
|
|
483
|
+
rd["new"] += 1
|
|
484
|
+
|
|
485
|
+
result = []
|
|
486
|
+
for rep_id in sorted(rep_data.keys()):
|
|
487
|
+
rd = rep_data[rep_id]
|
|
488
|
+
rep = rep_map.get(rep_id)
|
|
489
|
+
result.append(
|
|
490
|
+
TerritorySummary(
|
|
491
|
+
rep_id=rep_id,
|
|
492
|
+
rep_name=rep.name if rep else rep_id,
|
|
493
|
+
hcp_count=rd["hcp_count"],
|
|
494
|
+
total_potential=round(rd["total_potential"], 0),
|
|
495
|
+
workload_hours_weekly=round(rd["workload"], 1),
|
|
496
|
+
travel_hours_weekly=round(rd["travel"], 1),
|
|
497
|
+
segment_high=rd["seg_high"],
|
|
498
|
+
segment_medium=rd["seg_med"],
|
|
499
|
+
segment_low=rd["seg_low"],
|
|
500
|
+
relationships_kept=rd["kept"],
|
|
501
|
+
relationships_new=rd["new"],
|
|
502
|
+
)
|
|
503
|
+
)
|
|
504
|
+
return result
|
|
File without changes
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""ta_align — generate or adjust territory alignment (strategic mode)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class OverrideArg(BaseModel):
|
|
11
|
+
hcp_id: str = Field(description="HCP to pin to a specific rep")
|
|
12
|
+
rep_id: str = Field(description="Rep to assign")
|
|
13
|
+
reason: str = Field(default="", description="Why this override exists")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NewHireArg(BaseModel):
|
|
17
|
+
rep_id: str = Field(description="New rep identifier")
|
|
18
|
+
name: str = Field(description="New rep name")
|
|
19
|
+
base_lat: float = Field(description="Home-base latitude")
|
|
20
|
+
base_lng: float = Field(description="Home-base longitude")
|
|
21
|
+
product_expertise: list[str] = Field(
|
|
22
|
+
default_factory=list, description="Products the new rep covers (semicolon-separated)"
|
|
23
|
+
)
|
|
24
|
+
max_weekly_hours: float = Field(default=40.0, description="Weekly hour capacity")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AlignArgs(BaseModel):
|
|
28
|
+
scenario_name: str = Field(description="Label for this scenario, e.g. 'baseline' or 'minus_r004'")
|
|
29
|
+
weight_workload: float | None = Field(default=None, description="Override workload balance weight (default 0.30)")
|
|
30
|
+
weight_travel: float | None = Field(default=None, description="Override travel efficiency weight (default 0.25)")
|
|
31
|
+
weight_disruption: float | None = Field(default=None, description="Override disruption weight (default 0.25)")
|
|
32
|
+
weight_coverage: float | None = Field(default=None, description="Override coverage weight (default 0.20)")
|
|
33
|
+
vacancies: list[str] = Field(
|
|
34
|
+
default_factory=list,
|
|
35
|
+
description="Rep IDs to model as vacant; their HCPs are redistributed",
|
|
36
|
+
)
|
|
37
|
+
new_hires: list[NewHireArg] = Field(default_factory=list, description="New reps to add for this scenario")
|
|
38
|
+
overrides: list[OverrideArg] = Field(
|
|
39
|
+
default_factory=list, description="Manual HCP-to-rep pins the solver must respect"
|
|
40
|
+
)
|
|
41
|
+
lock_reps: list[str] = Field(
|
|
42
|
+
default_factory=list,
|
|
43
|
+
description="Rep IDs whose current HCPs must not be reassigned",
|
|
44
|
+
)
|
|
45
|
+
max_iterations: int = Field(default=2000, description="Max local-search iterations")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
TOOL: dict[str, Any] = {
|
|
49
|
+
"name": "ta_align",
|
|
50
|
+
"description": (
|
|
51
|
+
"Generate a territory alignment scenario. Assigns HCPs to reps under "
|
|
52
|
+
"multi-objective optimisation balancing workload, travel, disruption, "
|
|
53
|
+
"and coverage. Supports vacancies, new hires, manual overrides, and "
|
|
54
|
+
"locked reps. Results are saved as a named scenario for later "
|
|
55
|
+
"evaluation or comparison. Call ta_status first to confirm data is loaded."
|
|
56
|
+
),
|
|
57
|
+
"args": AlignArgs,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def handle(arguments: dict[str, Any]) -> list[dict[str, Any]]:
|
|
62
|
+
import json
|
|
63
|
+
|
|
64
|
+
from ..data import (
|
|
65
|
+
get_constraints,
|
|
66
|
+
get_current_alignment,
|
|
67
|
+
get_hcps,
|
|
68
|
+
get_reps,
|
|
69
|
+
is_loaded,
|
|
70
|
+
load_all,
|
|
71
|
+
save_scenario,
|
|
72
|
+
)
|
|
73
|
+
from ..models import NewHire, ObjectiveWeights, Override
|
|
74
|
+
from ..solver import solve
|
|
75
|
+
|
|
76
|
+
if not is_loaded():
|
|
77
|
+
load_all()
|
|
78
|
+
|
|
79
|
+
scenario_name = arguments["scenario_name"]
|
|
80
|
+
|
|
81
|
+
w = ObjectiveWeights()
|
|
82
|
+
if arguments.get("weight_workload") is not None:
|
|
83
|
+
w.workload_balance = arguments["weight_workload"]
|
|
84
|
+
if arguments.get("weight_travel") is not None:
|
|
85
|
+
w.travel_efficiency = arguments["weight_travel"]
|
|
86
|
+
if arguments.get("weight_disruption") is not None:
|
|
87
|
+
w.disruption = arguments["weight_disruption"]
|
|
88
|
+
if arguments.get("weight_coverage") is not None:
|
|
89
|
+
w.coverage = arguments["weight_coverage"]
|
|
90
|
+
|
|
91
|
+
vacancies = arguments.get("vacancies", [])
|
|
92
|
+
new_hires_raw = arguments.get("new_hires", [])
|
|
93
|
+
new_hires = (
|
|
94
|
+
[
|
|
95
|
+
NewHire(
|
|
96
|
+
rep_id=nh["rep_id"],
|
|
97
|
+
name=nh["name"],
|
|
98
|
+
base_lat=nh["base_lat"],
|
|
99
|
+
base_lng=nh["base_lng"],
|
|
100
|
+
product_expertise=nh.get("product_expertise", []),
|
|
101
|
+
max_weekly_hours=nh.get("max_weekly_hours", 40.0),
|
|
102
|
+
)
|
|
103
|
+
for nh in new_hires_raw
|
|
104
|
+
]
|
|
105
|
+
if new_hires_raw
|
|
106
|
+
else []
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
overrides_raw = arguments.get("overrides", [])
|
|
110
|
+
overrides = (
|
|
111
|
+
[Override(hcp_id=o["hcp_id"], rep_id=o["rep_id"], reason=o.get("reason", "")) for o in overrides_raw]
|
|
112
|
+
if overrides_raw
|
|
113
|
+
else []
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
lock_reps = arguments.get("lock_reps", [])
|
|
117
|
+
|
|
118
|
+
hcps = get_hcps()
|
|
119
|
+
reps = get_reps(vacancies=vacancies, new_hires=new_hires)
|
|
120
|
+
alignment = get_current_alignment()
|
|
121
|
+
constraints = get_constraints()
|
|
122
|
+
|
|
123
|
+
result = solve(
|
|
124
|
+
hcps=hcps,
|
|
125
|
+
reps=reps,
|
|
126
|
+
current_alignment=alignment,
|
|
127
|
+
constraints=constraints,
|
|
128
|
+
weights=w,
|
|
129
|
+
overrides=overrides,
|
|
130
|
+
lock_reps=lock_reps,
|
|
131
|
+
scenario_name=scenario_name,
|
|
132
|
+
max_iterations=arguments.get("max_iterations", 2000),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
result_dict = json.loads(result.model_dump_json())
|
|
136
|
+
save_scenario(scenario_name, result_dict)
|
|
137
|
+
|
|
138
|
+
return [{"type": "text", "text": json.dumps(result_dict, indent=2)}]
|