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,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)}]