open-pharma-plugins 2.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. mcp_framework.py +495 -0
  2. open_pharma_plugins-2.2.0.dist-info/METADATA +135 -0
  3. open_pharma_plugins-2.2.0.dist-info/RECORD +136 -0
  4. open_pharma_plugins-2.2.0.dist-info/WHEEL +5 -0
  5. open_pharma_plugins-2.2.0.dist-info/entry_points.txt +8 -0
  6. open_pharma_plugins-2.2.0.dist-info/licenses/LICENSE +202 -0
  7. open_pharma_plugins-2.2.0.dist-info/top_level.txt +8 -0
  8. open_pharma_plugins_campaign_studio/__init__.py +14 -0
  9. open_pharma_plugins_campaign_studio/__main__.py +11 -0
  10. open_pharma_plugins_campaign_studio/_campaign_store.py +162 -0
  11. open_pharma_plugins_campaign_studio/_claim_engine.py +262 -0
  12. open_pharma_plugins_campaign_studio/_renderer.py +119 -0
  13. open_pharma_plugins_campaign_studio/fixtures/brand_kit/legal.json +21 -0
  14. open_pharma_plugins_campaign_studio/fixtures/brand_kit/logo.svg +4 -0
  15. open_pharma_plugins_campaign_studio/fixtures/brand_kit/palette.json +11 -0
  16. open_pharma_plugins_campaign_studio/fixtures/brand_kit/product.png +1 -0
  17. open_pharma_plugins_campaign_studio/fixtures/brand_kit/typography.json +14 -0
  18. open_pharma_plugins_campaign_studio/fixtures/sample_approved_claims.json +119 -0
  19. open_pharma_plugins_campaign_studio/models/__init__.py +34 -0
  20. open_pharma_plugins_campaign_studio/models/_common.py +12 -0
  21. open_pharma_plugins_campaign_studio/models/brief.py +72 -0
  22. open_pharma_plugins_campaign_studio/models/claims.py +15 -0
  23. open_pharma_plugins_campaign_studio/models/copy.py +47 -0
  24. open_pharma_plugins_campaign_studio/models/journey.py +21 -0
  25. open_pharma_plugins_campaign_studio/models/message.py +25 -0
  26. open_pharma_plugins_campaign_studio/models/mlr.py +29 -0
  27. open_pharma_plugins_campaign_studio/models/validation.py +32 -0
  28. open_pharma_plugins_campaign_studio/policy/rules.json +79 -0
  29. open_pharma_plugins_campaign_studio/templates/banner.svg.j2 +26 -0
  30. open_pharma_plugins_campaign_studio/templates/email.html.j2 +54 -0
  31. open_pharma_plugins_campaign_studio/tools/__init__.py +0 -0
  32. open_pharma_plugins_campaign_studio/tools/create_campaign_brief.py +212 -0
  33. open_pharma_plugins_campaign_studio/tools/generate_audience_journey.py +129 -0
  34. open_pharma_plugins_campaign_studio/tools/generate_channel_copy.py +199 -0
  35. open_pharma_plugins_campaign_studio/tools/generate_message_architecture.py +121 -0
  36. open_pharma_plugins_campaign_studio/tools/package_mlr_submission.py +230 -0
  37. open_pharma_plugins_campaign_studio/tools/render_banner.py +99 -0
  38. open_pharma_plugins_campaign_studio/tools/render_email.py +101 -0
  39. open_pharma_plugins_campaign_studio/tools/render_poster.py +222 -0
  40. open_pharma_plugins_campaign_studio/tools/retrieve_approved_claims.py +71 -0
  41. open_pharma_plugins_campaign_studio/tools/retrieve_brand_components.py +76 -0
  42. open_pharma_plugins_campaign_studio/tools/validate_claims_and_fair_balance.py +285 -0
  43. open_pharma_plugins_competitive_intelligence/__init__.py +13 -0
  44. open_pharma_plugins_competitive_intelligence/__main__.py +11 -0
  45. open_pharma_plugins_competitive_intelligence/_artifacts.py +87 -0
  46. open_pharma_plugins_competitive_intelligence/_cache.py +144 -0
  47. open_pharma_plugins_competitive_intelligence/_clinical_trials.py +569 -0
  48. open_pharma_plugins_competitive_intelligence/_dailymed.py +260 -0
  49. open_pharma_plugins_competitive_intelligence/_fda.py +255 -0
  50. open_pharma_plugins_competitive_intelligence/_pubmed.py +342 -0
  51. open_pharma_plugins_competitive_intelligence/_regulatory.py +140 -0
  52. open_pharma_plugins_competitive_intelligence/_runs.py +278 -0
  53. open_pharma_plugins_competitive_intelligence/_transport.py +83 -0
  54. open_pharma_plugins_competitive_intelligence/_watchlist.py +113 -0
  55. open_pharma_plugins_competitive_intelligence/_web_search.py +331 -0
  56. open_pharma_plugins_competitive_intelligence/models.py +525 -0
  57. open_pharma_plugins_competitive_intelligence/tools/__init__.py +0 -0
  58. open_pharma_plugins_competitive_intelligence/tools/ci_extract_events.py +247 -0
  59. open_pharma_plugins_competitive_intelligence/tools/ci_landscape.py +195 -0
  60. open_pharma_plugins_competitive_intelligence/tools/ci_refresh.py +101 -0
  61. open_pharma_plugins_competitive_intelligence/tools/ci_report.py +402 -0
  62. open_pharma_plugins_competitive_intelligence/tools/ci_scan_news.py +48 -0
  63. open_pharma_plugins_competitive_intelligence/tools/ci_scan_publications.py +46 -0
  64. open_pharma_plugins_competitive_intelligence/tools/ci_scan_regulatory.py +64 -0
  65. open_pharma_plugins_competitive_intelligence/tools/ci_scan_trials.py +85 -0
  66. open_pharma_plugins_competitive_intelligence/tools/ci_status.py +106 -0
  67. open_pharma_plugins_competitive_intelligence/tools/ci_timeline.py +453 -0
  68. open_pharma_plugins_competitive_intelligence/tools/ci_track.py +121 -0
  69. open_pharma_plugins_competitive_intelligence/tools/ci_trial_detail.py +55 -0
  70. open_pharma_plugins_field_training/__init__.py +13 -0
  71. open_pharma_plugins_field_training/__main__.py +11 -0
  72. open_pharma_plugins_field_training/_content_store.py +131 -0
  73. open_pharma_plugins_field_training/_grounding.py +75 -0
  74. open_pharma_plugins_field_training/_html_renderers.py +546 -0
  75. open_pharma_plugins_field_training/fixtures/sample_product_message.pdf +156 -0
  76. open_pharma_plugins_field_training/fixtures/sample_training_deck.pptx +0 -0
  77. open_pharma_plugins_field_training/models.py +265 -0
  78. open_pharma_plugins_field_training/tools/__init__.py +0 -0
  79. open_pharma_plugins_field_training/tools/get_document_page.py +67 -0
  80. open_pharma_plugins_field_training/tools/ingest_document.py +147 -0
  81. open_pharma_plugins_field_training/tools/list_documents.py +51 -0
  82. open_pharma_plugins_field_training/tools/render_output.py +118 -0
  83. open_pharma_plugins_field_training/tools/search_content.py +57 -0
  84. open_pharma_plugins_hcp_intelligence/__init__.py +14 -0
  85. open_pharma_plugins_hcp_intelligence/__main__.py +11 -0
  86. open_pharma_plugins_hcp_intelligence/_crm_store.py +70 -0
  87. open_pharma_plugins_hcp_intelligence/batch.py +891 -0
  88. open_pharma_plugins_hcp_intelligence/batch_cli.py +221 -0
  89. open_pharma_plugins_hcp_intelligence/batch_csv.py +206 -0
  90. open_pharma_plugins_hcp_intelligence/fixtures/sample_accounts.csv +27 -0
  91. open_pharma_plugins_hcp_intelligence/models.py +356 -0
  92. open_pharma_plugins_hcp_intelligence/tools/__init__.py +0 -0
  93. open_pharma_plugins_hcp_intelligence/tools/get_account.py +48 -0
  94. open_pharma_plugins_hcp_intelligence/tools/list_accounts.py +66 -0
  95. open_pharma_plugins_hcp_intelligence/tools/search_clinical_trials.py +175 -0
  96. open_pharma_plugins_hcp_intelligence/tools/search_congresses.py +134 -0
  97. open_pharma_plugins_hcp_intelligence/tools/search_grants.py +181 -0
  98. open_pharma_plugins_hcp_intelligence/tools/search_guidelines.py +238 -0
  99. open_pharma_plugins_hcp_intelligence/tools/search_hco_web.py +73 -0
  100. open_pharma_plugins_hcp_intelligence/tools/search_hcp_web.py +199 -0
  101. open_pharma_plugins_hcp_intelligence/tools/search_orcid.py +214 -0
  102. open_pharma_plugins_hcp_intelligence/tools/search_publications.py +207 -0
  103. open_pharma_plugins_hcp_intelligence/tools/update_account.py +84 -0
  104. open_pharma_plugins_next_best_engagement/__init__.py +14 -0
  105. open_pharma_plugins_next_best_engagement/__main__.py +11 -0
  106. open_pharma_plugins_next_best_engagement/_optimizer.py +400 -0
  107. open_pharma_plugins_next_best_engagement/_renderer.py +149 -0
  108. open_pharma_plugins_next_best_engagement/_scoring.py +82 -0
  109. open_pharma_plugins_next_best_engagement/_universe.py +145 -0
  110. open_pharma_plugins_next_best_engagement/fixtures/sample_universe.csv +81 -0
  111. open_pharma_plugins_next_best_engagement/models.py +135 -0
  112. open_pharma_plugins_next_best_engagement/tools/__init__.py +0 -0
  113. open_pharma_plugins_next_best_engagement/tools/load_universe.py +47 -0
  114. open_pharma_plugins_next_best_engagement/tools/recommend_engagements.py +80 -0
  115. open_pharma_plugins_next_best_engagement/tools/render_plan.py +90 -0
  116. open_pharma_plugins_territory_alignment/__init__.py +14 -0
  117. open_pharma_plugins_territory_alignment/__main__.py +11 -0
  118. open_pharma_plugins_territory_alignment/data.py +300 -0
  119. open_pharma_plugins_territory_alignment/fixtures/constraints.csv +11 -0
  120. open_pharma_plugins_territory_alignment/fixtures/current_alignment.csv +81 -0
  121. open_pharma_plugins_territory_alignment/fixtures/hcps.csv +81 -0
  122. open_pharma_plugins_territory_alignment/fixtures/reps.csv +9 -0
  123. open_pharma_plugins_territory_alignment/geo.py +175 -0
  124. open_pharma_plugins_territory_alignment/models.py +201 -0
  125. open_pharma_plugins_territory_alignment/scoring.py +125 -0
  126. open_pharma_plugins_territory_alignment/solver.py +504 -0
  127. open_pharma_plugins_territory_alignment/tools/__init__.py +0 -0
  128. open_pharma_plugins_territory_alignment/tools/ta_align.py +138 -0
  129. open_pharma_plugins_territory_alignment/tools/ta_cluster.py +247 -0
  130. open_pharma_plugins_territory_alignment/tools/ta_compare.py +173 -0
  131. open_pharma_plugins_territory_alignment/tools/ta_evaluate.py +119 -0
  132. open_pharma_plugins_territory_alignment/tools/ta_status.py +34 -0
  133. open_pharma_plugins_territory_alignment/tools/ta_visualize.py +504 -0
  134. shared/__init__.py +11 -0
  135. shared/env.py +217 -0
  136. shared/filesystem.py +110 -0
@@ -0,0 +1,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
@@ -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)}]