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,14 @@
1
+ from mcp_framework import build_registry
2
+
3
+ __version__ = "1.0.1"
4
+
5
+ SPECS, get_handler, list_tools = build_registry(__name__, ["tools"])
6
+
7
+ USAGE_NOTE = (
8
+ "Territory Alignment assigns HCPs to representatives (strategic mode) "
9
+ "and plans visit routes (operational mode), balancing workload, travel, "
10
+ "relationship continuity, and priority coverage. Supports named scenario "
11
+ "comparison for what-if planning."
12
+ )
13
+
14
+ SYSTEM_DEPS = []
@@ -0,0 +1,11 @@
1
+ from pathlib import Path
2
+
3
+ from mcp_framework import run_main
4
+
5
+
6
+ def main() -> None:
7
+ run_main(__package__ or Path(__file__).resolve().parent.name)
8
+
9
+
10
+ if __name__ == "__main__":
11
+ main()
@@ -0,0 +1,300 @@
1
+ """Data loading, scenario persistence, and fixture fallback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import io
7
+ import json
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from shared.filesystem import atomic_write_json, atomic_write_text, ensure_private_dir
12
+
13
+ from .models import HCP, Constraint, CurrentAssignment, NewHire, Rep
14
+
15
+
16
+ def _data_dir() -> Path:
17
+ from shared.env import get_env
18
+
19
+ custom = get_env("OPEN_PHARMA_TA_DATA_DIR", "")
20
+ if custom:
21
+ return Path(custom)
22
+ return Path(__file__).parent / "fixtures"
23
+
24
+
25
+ def _scenarios_dir() -> Path:
26
+ from shared.env import get_env
27
+
28
+ custom = get_env("OPEN_PHARMA_TA_SCENARIOS_DIR", "")
29
+ if custom:
30
+ d = Path(custom)
31
+ else:
32
+ d = Path.home() / ".open-pharma-plugins" / "territory-alignment" / "scenarios"
33
+ return ensure_private_dir(d)
34
+
35
+
36
+ # -- state --
37
+
38
+ _hcps: list[HCP] = []
39
+ _reps: list[Rep] = []
40
+ _current_alignment: list[CurrentAssignment] = []
41
+ _constraints: list[Constraint] = []
42
+ _loaded: bool = False
43
+
44
+
45
+ def is_loaded() -> bool:
46
+ return _loaded
47
+
48
+
49
+ def load_all() -> dict[str, Any]:
50
+ """Load all CSV files from the data directory."""
51
+ global _hcps, _reps, _current_alignment, _constraints, _loaded
52
+
53
+ data_dir = _data_dir()
54
+ _hcps = _load_hcps(data_dir / "hcps.csv")
55
+ _reps = _load_reps(data_dir / "reps.csv")
56
+ _current_alignment = _load_alignment(data_dir / "current_alignment.csv")
57
+ _constraints = _load_constraints(data_dir / "constraints.csv")
58
+ _loaded = True
59
+
60
+ return get_summary()
61
+
62
+
63
+ def get_hcps() -> list[HCP]:
64
+ return list(_hcps)
65
+
66
+
67
+ def get_reps(
68
+ vacancies: list[str] | None = None,
69
+ new_hires: list[NewHire] | None = None,
70
+ ) -> list[Rep]:
71
+ """Return reps with vacancies removed and new hires added."""
72
+ vacancy_set = set(vacancies or [])
73
+ result = [r for r in _reps if r.rep_id not in vacancy_set]
74
+ for nh in new_hires or []:
75
+ result.append(
76
+ Rep(
77
+ rep_id=nh.rep_id,
78
+ name=nh.name,
79
+ base_lat=nh.base_lat,
80
+ base_lng=nh.base_lng,
81
+ product_expertise=nh.product_expertise,
82
+ max_weekly_hours=nh.max_weekly_hours,
83
+ )
84
+ )
85
+ return result
86
+
87
+
88
+ def get_current_alignment() -> list[CurrentAssignment]:
89
+ return list(_current_alignment)
90
+
91
+
92
+ def get_constraints() -> list[Constraint]:
93
+ return list(_constraints)
94
+
95
+
96
+ def get_summary() -> dict[str, Any]:
97
+ if not _loaded:
98
+ return {"loaded": False}
99
+
100
+ segments: dict[str, int] = {}
101
+ geo_count = 0
102
+ for h in _hcps:
103
+ segments[h.segment] = segments.get(h.segment, 0) + 1
104
+ if h.lat is not None and h.lng is not None:
105
+ geo_count += 1
106
+
107
+ assigned_hcps = {a.hcp_id for a in _current_alignment}
108
+ unassigned = [h.hcp_id for h in _hcps if h.hcp_id not in assigned_hcps]
109
+
110
+ return {
111
+ "loaded": True,
112
+ "data_source": str(_data_dir()),
113
+ "hcp_count": len(_hcps),
114
+ "rep_count": len(_reps),
115
+ "alignment_count": len(_current_alignment),
116
+ "constraint_count": len(_constraints),
117
+ "segments": segments,
118
+ "geocoded_pct": round(geo_count / max(len(_hcps), 1) * 100, 1),
119
+ "unassigned_hcps": unassigned,
120
+ "scenarios": list_scenarios(),
121
+ }
122
+
123
+
124
+ # -- scenarios --
125
+
126
+
127
+ def _sanitize_scenario_name(name: str) -> str:
128
+ import re
129
+
130
+ clean = re.sub(r"[^a-zA-Z0-9_\-]", "_", name)
131
+ if not clean:
132
+ raise ValueError("scenario name must contain at least one alphanumeric character")
133
+ return clean
134
+
135
+
136
+ def save_scenario(name: str, result: dict[str, Any]) -> str:
137
+ safe = _sanitize_scenario_name(name)
138
+ d = _scenarios_dir()
139
+ json_path = d / f"{safe}.json"
140
+ atomic_write_json(json_path, result)
141
+ _write_scenario_csvs(d, safe, result)
142
+ return str(json_path)
143
+
144
+
145
+ def _write_scenario_csvs(directory: Path, name: str, result: dict[str, Any]) -> None:
146
+ assignments = result.get("assignments", [])
147
+ if assignments:
148
+ _ASSIGN_COLS = [
149
+ "hcp_id",
150
+ "hcp_name",
151
+ "primary_rep",
152
+ "previous_rep",
153
+ "is_changed",
154
+ "change_reason",
155
+ "estimated_travel_min",
156
+ "estimated_annual_visits",
157
+ "segment",
158
+ "tier",
159
+ ]
160
+ a_path = directory / f"{name}_assignments.csv"
161
+ buffer = io.StringIO(newline="")
162
+ writer = csv.DictWriter(buffer, fieldnames=_ASSIGN_COLS, extrasaction="ignore")
163
+ writer.writeheader()
164
+ writer.writerows(assignments)
165
+ atomic_write_text(a_path, buffer.getvalue())
166
+
167
+ territories = result.get("territory_summary", [])
168
+ if territories:
169
+ _TERR_COLS = [
170
+ "rep_id",
171
+ "rep_name",
172
+ "hcp_count",
173
+ "total_potential",
174
+ "workload_hours_weekly",
175
+ "travel_hours_weekly",
176
+ "segment_high",
177
+ "segment_medium",
178
+ "segment_low",
179
+ "relationships_kept",
180
+ "relationships_new",
181
+ ]
182
+ t_path = directory / f"{name}_territory_summary.csv"
183
+ buffer = io.StringIO(newline="")
184
+ writer = csv.DictWriter(buffer, fieldnames=_TERR_COLS, extrasaction="ignore")
185
+ writer.writeheader()
186
+ writer.writerows(territories)
187
+ atomic_write_text(t_path, buffer.getvalue())
188
+
189
+
190
+ def load_scenario(name: str) -> dict[str, Any] | None:
191
+ path = _scenarios_dir() / f"{_sanitize_scenario_name(name)}.json"
192
+ if path.exists():
193
+ return json.loads(path.read_text())
194
+ return None
195
+
196
+
197
+ def list_scenarios() -> list[dict[str, str]]:
198
+ result = []
199
+ for f in sorted(_scenarios_dir().glob("*.json")):
200
+ result.append({"name": f.stem, "file": str(f)})
201
+ return result
202
+
203
+
204
+ # -- CSV loaders --
205
+
206
+
207
+ def _load_hcps(path: Path) -> list[HCP]:
208
+ if not path.exists():
209
+ return []
210
+ rows: list[HCP] = []
211
+ with open(path, newline="") as f:
212
+ for raw in csv.DictReader(f):
213
+ _coerce_hcp(raw)
214
+ rows.append(HCP.model_validate(raw))
215
+ return rows
216
+
217
+
218
+ def _coerce_hcp(raw: dict[str, Any]) -> None:
219
+ for field in ("lat", "lng", "annual_potential"):
220
+ if field in raw:
221
+ val = raw[field]
222
+ if not val:
223
+ raw[field] = None if field in ("lat", "lng") else 0.0
224
+ else:
225
+ raw[field] = float(val)
226
+ for field in ("consent_email", "consent_phone", "consent_visit"):
227
+ if field in raw:
228
+ raw[field] = raw[field].lower() in ("true", "1", "yes")
229
+ if "tier" in raw and raw["tier"]:
230
+ raw["tier"] = int(raw["tier"])
231
+ if "product_requirements" in raw:
232
+ val = raw["product_requirements"]
233
+ raw["product_requirements"] = [v.strip() for v in val.split(";")] if val else []
234
+ _coerce_extras(raw, _HCP_KNOWN_FIELDS)
235
+
236
+
237
+ _HCP_KNOWN_FIELDS = set(HCP.model_fields.keys())
238
+
239
+
240
+ def _load_reps(path: Path) -> list[Rep]:
241
+ if not path.exists():
242
+ return []
243
+ rows: list[Rep] = []
244
+ with open(path, newline="") as f:
245
+ for raw in csv.DictReader(f):
246
+ _coerce_rep(raw)
247
+ rows.append(Rep.model_validate(raw))
248
+ return rows
249
+
250
+
251
+ def _coerce_rep(raw: dict[str, Any]) -> None:
252
+ for field in ("base_lat", "base_lng", "max_weekly_hours"):
253
+ if field in raw and raw[field]:
254
+ raw[field] = float(raw[field])
255
+ if "max_daily_calls" in raw and raw["max_daily_calls"]:
256
+ raw["max_daily_calls"] = int(raw["max_daily_calls"])
257
+ for list_field in ("product_expertise", "available_days"):
258
+ if list_field in raw:
259
+ val = raw[list_field]
260
+ raw[list_field] = [v.strip() for v in val.split(";")] if val else []
261
+
262
+
263
+ def _load_alignment(path: Path) -> list[CurrentAssignment]:
264
+ if not path.exists():
265
+ return []
266
+ rows: list[CurrentAssignment] = []
267
+ with open(path, newline="") as f:
268
+ for raw in csv.DictReader(f):
269
+ rows.append(CurrentAssignment.model_validate(raw))
270
+ return rows
271
+
272
+
273
+ def _load_constraints(path: Path) -> list[Constraint]:
274
+ if not path.exists():
275
+ return []
276
+ rows: list[Constraint] = []
277
+ with open(path, newline="") as f:
278
+ for raw in csv.DictReader(f):
279
+ rows.append(Constraint.model_validate(raw))
280
+ return rows
281
+
282
+
283
+ def _coerce_extras(raw: dict[str, Any], known: set[str]) -> None:
284
+ for key in raw:
285
+ if key in known:
286
+ continue
287
+ val = raw[key]
288
+ if not isinstance(val, str) or not val:
289
+ continue
290
+ low = val.lower()
291
+ if low in ("true", "false"):
292
+ raw[key] = low == "true"
293
+ continue
294
+ try:
295
+ raw[key] = int(val)
296
+ except ValueError:
297
+ try:
298
+ raw[key] = float(val)
299
+ except ValueError:
300
+ pass
@@ -0,0 +1,11 @@
1
+ type,scope,value,description
2
+ product_match,global,required,Rep must have product expertise matching HCP requirements
3
+ account_grouping,account:A001,same_primary_rep,All HCPs in account A001 share one primary rep
4
+ account_grouping,account:A005,same_primary_rep,All HCPs in account A005 share one primary rep
5
+ account_grouping,account:A009,same_primary_rep,All HCPs in account A009 share one primary rep
6
+ account_grouping,account:A013,same_primary_rep,All HCPs in account A013 share one primary rep
7
+ account_grouping,account:A014,same_primary_rep,All HCPs in account A014 share one primary rep
8
+ max_hcps_per_rep,global,18,No rep gets more than 18 HCPs
9
+ frequency_cap,segment:high,2,Max weeks between visits for high-segment HCPs
10
+ frequency_cap,segment:medium,4,Max weeks between visits for medium-segment HCPs
11
+ frequency_cap,segment:low,8,Max weeks between visits for low-segment HCPs
@@ -0,0 +1,81 @@
1
+ hcp_id,primary_rep,secondary_rep
2
+ H001,R001,
3
+ H002,R001,
4
+ H003,R001,
5
+ H004,R002,
6
+ H005,R001,
7
+ H006,R001,
8
+ H007,R002,
9
+ H008,R001,
10
+ H009,R002,
11
+ H010,R001,
12
+ H011,R001,
13
+ H012,R001,
14
+ H013,R002,
15
+ H014,R001,
16
+ H015,R001,
17
+ H016,R002,
18
+ H017,R001,
19
+ H018,R002,
20
+ H019,R001,
21
+ H020,R002,
22
+ H021,R003,
23
+ H022,R003,
24
+ H023,R003,
25
+ H024,R003,
26
+ H025,R003,
27
+ H026,R003,
28
+ H027,R004,
29
+ H028,R004,
30
+ H029,R003,
31
+ H030,R003,
32
+ H031,R004,
33
+ H032,R003,
34
+ H033,R004,
35
+ H034,R003,
36
+ H035,R004,
37
+ H036,R004,
38
+ H037,R004,
39
+ H038,R003,
40
+ H039,R004,
41
+ H040,R003,
42
+ H041,R005,
43
+ H042,R005,
44
+ H043,R006,
45
+ H044,R005,
46
+ H045,R005,
47
+ H046,R006,
48
+ H047,R005,
49
+ H048,R005,
50
+ H049,R005,
51
+ H050,R006,
52
+ H051,R005,
53
+ H052,R006,
54
+ H053,R005,
55
+ H054,R005,
56
+ H055,R006,
57
+ H056,R005,
58
+ H057,R006,
59
+ H058,R005,
60
+ H059,R006,
61
+ H060,R006,
62
+ H061,R007,
63
+ H062,R008,
64
+ H063,R007,
65
+ H064,R007,
66
+ H065,R007,
67
+ H066,R008,
68
+ H067,R007,
69
+ H068,R008,
70
+ H069,R007,
71
+ H070,R007,
72
+ H071,R007,
73
+ H072,R008,
74
+ H073,R007,
75
+ H074,R007,
76
+ H075,R007,
77
+ H076,R008,
78
+ H077,R007,
79
+ H078,R008,
80
+ H079,R007,
81
+ H080,R008,
@@ -0,0 +1,81 @@
1
+ hcp_id,name,specialty,segment,tier,lat,lng,account_id,consent_email,consent_phone,consent_visit,annual_potential,product_requirements
2
+ H001,Dr. Sarah Chen,oncology,high,1,40.7648,-73.9653,A001,true,true,true,95000,product_a
3
+ H002,Dr. Michael Torres,cardiology,high,1,40.7505,-73.9764,A001,true,true,true,88000,product_b
4
+ H003,Dr. Priya Sharma,oncology,high,1,40.7282,-74.0060,A001,true,true,true,82000,product_a
5
+ H004,Dr. David Okafor,neurology,high,1,40.6892,-73.9857,A002,true,true,true,78000,product_a;product_b
6
+ H005,Dr. Rachel Kim,cardiology,medium,2,40.7580,-73.9855,A001,true,true,true,45000,product_b
7
+ H006,Dr. Thomas Wright,oncology,medium,2,40.7425,-73.9540,A002,true,false,true,42000,product_a
8
+ H007,Dr. Amara Osei,immunology,medium,2,40.6945,-73.9565,A002,true,true,true,38000,product_b
9
+ H008,Dr. Jennifer Liu,cardiology,medium,2,40.7614,-73.9776,A003,true,true,true,36000,product_a
10
+ H009,Dr. Robert Patel,oncology,medium,2,40.6782,-73.9442,A002,true,true,true,34000,product_a;product_b
11
+ H010,Dr. Fatima Al-Hassan,neurology,medium,2,40.7831,-73.9712,A003,true,true,true,32000,product_b
12
+ H011,Dr. Andrew Nowak,oncology,low,3,40.7505,-73.9934,,true,true,true,12000,product_a
13
+ H012,Dr. Maria Santos,cardiology,low,3,40.7261,-74.0014,,true,true,true,10000,product_b
14
+ H013,Dr. Kevin Zhao,immunology,low,3,40.7112,-73.9505,,true,false,true,9000,product_a
15
+ H014,Dr. Laura Becker,neurology,low,3,40.7400,-74.0055,,true,true,true,8500,product_b
16
+ H015,Dr. Hassan Yusuf,primary_care,low,3,40.7680,-73.9510,,false,false,true,7000,product_a
17
+ H016,Dr. Emily Strand,oncology,low,3,40.6800,-73.9750,,true,true,true,6500,product_a
18
+ H017,Dr. Daniel Cho,cardiology,low,3,40.7527,-73.9772,,true,true,true,6000,product_b
19
+ H018,Dr. Olivia Martin,primary_care,low,3,40.6950,-73.9300,,true,true,true,5500,product_a;product_b
20
+ H019,Dr. Raj Gupta,oncology,low,3,40.7453,-73.9882,,true,true,true,5000,product_a
21
+ H020,Dr. Susan O'Neill,immunology,low,3,40.7020,-73.9880,,true,true,true,4500,product_b
22
+ H021,Dr. James Nakamura,oncology,high,1,40.7425,-74.0310,A004,true,true,true,92000,product_a
23
+ H022,Dr. Lisa Johansson,immunology,high,1,40.7282,-74.0776,A004,true,true,true,85000,product_b
24
+ H023,Dr. Wei Zhang,neurology,high,1,40.7178,-74.0431,A004,true,true,true,80000,product_a;product_b
25
+ H024,Dr. Patricia Reyes,cardiology,high,1,40.7357,-74.0298,A005,true,true,true,76000,product_a
26
+ H025,Dr. Mark Thompson,oncology,medium,2,40.7210,-74.0480,A004,true,true,true,44000,product_a
27
+ H026,Dr. Anna Kowalski,immunology,medium,2,40.7480,-74.0280,A005,true,false,true,40000,product_b
28
+ H027,Dr. Bryan Jackson,neurology,medium,2,40.7100,-74.0650,A005,true,true,true,37000,product_a
29
+ H028,Dr. Diana Petrova,cardiology,medium,2,40.7320,-74.0720,,true,true,true,35000,product_b
30
+ H029,Dr. George Fernandez,oncology,medium,2,40.7540,-74.0230,,true,true,true,33000,product_a;product_b
31
+ H030,Dr. Helen Tran,immunology,medium,2,40.7060,-74.0120,,true,true,true,31000,product_b
32
+ H031,Dr. Ivan Smirnov,neurology,low,3,40.7380,-74.0510,,true,true,true,11000,product_a
33
+ H032,Dr. Karen Lee,oncology,low,3,40.7150,-74.0340,,true,true,true,9500,product_b
34
+ H033,Dr. Nathan Cole,cardiology,low,3,40.7250,-74.0630,,true,true,true,8800,product_a
35
+ H034,Dr. Priscilla Dubois,immunology,low,3,40.7450,-74.0180,,true,false,true,8000,product_b
36
+ H035,Dr. Oscar Herrera,primary_care,low,3,40.7310,-74.0430,,false,false,true,7200,product_a
37
+ H036,Dr. Yuki Tanaka,oncology,low,3,40.7200,-74.0550,,true,true,true,6800,product_a
38
+ H037,Dr. Frank Bauer,neurology,low,3,40.7060,-74.0700,,true,true,true,5800,product_b
39
+ H038,Dr. Gloria Vega,cardiology,low,3,40.7350,-74.0150,,true,true,true,5200,product_a;product_b
40
+ H039,Dr. Henry Olsen,oncology,low,3,40.7470,-74.0350,,true,true,true,4800,product_a
41
+ H040,Dr. Irene Costa,immunology,low,3,40.7120,-74.0260,,true,true,true,4200,product_b
42
+ H041,Dr. Richard Muller,cardiology,high,1,40.7440,-74.1690,A006,true,true,true,90000,product_a
43
+ H042,Dr. Sophia Andersen,oncology,high,1,40.7357,-74.1724,A006,true,true,true,86000,product_b
44
+ H043,Dr. Charles Obi,neurology,high,1,40.7580,-74.2260,A007,true,true,true,79000,product_a;product_b
45
+ H044,Dr. Nina Volkov,immunology,high,1,40.7282,-74.0776,A006,true,true,true,75000,product_a
46
+ H045,Dr. Peter Hoffman,cardiology,medium,2,40.7200,-74.1730,A006,true,true,true,43000,product_b
47
+ H046,Dr. Aisha Nkomo,oncology,medium,2,40.7610,-74.2100,A007,true,false,true,39000,product_a
48
+ H047,Dr. Victor Ionescu,neurology,medium,2,40.7400,-74.1550,,true,true,true,36500,product_b
49
+ H048,Dr. Stephanie Cruz,cardiology,medium,2,40.7500,-74.1850,A007,true,true,true,34500,product_a
50
+ H049,Dr. Albert Schneider,oncology,medium,2,40.7100,-74.1600,,false,true,true,32500,product_a;product_b
51
+ H050,Dr. Beatrice Adeyemi,immunology,medium,2,40.7650,-74.2350,A008,true,true,true,30500,product_b
52
+ H051,Dr. Carl Lindqvist,cardiology,low,3,40.7300,-74.1900,,true,true,true,11500,product_a
53
+ H052,Dr. Diane Washington,oncology,low,3,40.7450,-74.2000,,true,true,true,10500,product_b
54
+ H053,Dr. Eduardo Silva,neurology,low,3,40.7550,-74.1750,,true,true,true,9200,product_a
55
+ H054,Dr. Fiona McCarthy,immunology,low,3,40.7150,-74.1650,,true,false,true,8200,product_b
56
+ H055,Dr. Greg Antonov,primary_care,low,3,40.7350,-74.2100,,false,false,true,7100,product_a
57
+ H056,Dr. Hannah Eriksson,oncology,low,3,40.7250,-74.1580,,true,true,true,6200,product_a
58
+ H057,Dr. Ian Fletcher,cardiology,low,3,40.7480,-74.1930,,true,true,true,5600,product_b
59
+ H058,Dr. Julia Romano,neurology,low,3,40.7050,-74.1700,,true,true,true,5100,product_a;product_b
60
+ H059,Dr. Kyle Abrams,oncology,low,3,40.7380,-74.2050,,true,true,true,4600,product_a
61
+ H060,Dr. Monica Dupont,immunology,low,3,40.7520,-74.2200,,true,true,true,4100,product_b
62
+ H061,Dr. Antonio Morales,oncology,high,1,40.8580,-73.8690,A009,true,true,true,91000,product_a
63
+ H062,Dr. Natalie Bergstrom,neurology,high,1,40.8128,-73.9291,A009,true,true,true,84000,product_b
64
+ H063,Dr. Philip Owusu,cardiology,high,1,40.8449,-73.8648,A009,true,true,true,77000,product_a;product_b
65
+ H064,Dr. Camille Bonnet,immunology,high,1,40.9176,-73.8984,A010,true,true,true,74000,product_a
66
+ H065,Dr. Samuel Okonkwo,oncology,medium,2,40.8400,-73.8450,A009,true,true,true,42000,product_a
67
+ H066,Dr. Teresa Ramirez,neurology,medium,2,40.8200,-73.9100,,true,false,true,38500,product_b
68
+ H067,Dr. Ulrich Weber,cardiology,medium,2,40.8650,-73.8880,,true,true,true,36000,product_a
69
+ H068,Dr. Vanessa Boyd,oncology,medium,2,40.8000,-73.9350,A010,true,true,true,34000,product_b
70
+ H069,Dr. William Fraser,immunology,medium,2,40.8300,-73.8750,,false,true,true,32000,product_a;product_b
71
+ H070,Dr. Xena Papadopoulos,neurology,medium,2,40.9050,-73.9100,A010,true,true,true,30000,product_b
72
+ H071,Dr. Yosef Abadi,oncology,low,3,40.8550,-73.8600,,true,true,true,11200,product_a
73
+ H072,Dr. Zoe Campbell,cardiology,low,3,40.8100,-73.9200,,true,true,true,10000,product_b
74
+ H073,Dr. Adam Kowalczyk,neurology,low,3,40.8700,-73.8950,,true,true,true,9100,product_a
75
+ H074,Dr. Bella Nguyen,immunology,low,3,40.8350,-73.8500,,true,false,true,7800,product_b
76
+ H075,Dr. Cedric Dumont,primary_care,low,3,40.8950,-73.9000,,false,false,true,6800,product_a
77
+ H076,Dr. Dina Khalil,oncology,low,3,40.8250,-73.9400,,true,true,true,6300,product_a
78
+ H077,Dr. Ethan Ross,cardiology,low,3,40.8480,-73.8780,,true,true,true,5400,product_b
79
+ H078,Dr. Freya Lindgren,neurology,low,3,40.8150,-73.9050,,true,true,true,4900,product_a;product_b
80
+ H079,Dr. Gavin Murphy,oncology,low,3,40.8600,-73.8850,,true,true,true,4400,product_a
81
+ H080,Dr. Hana Takahashi,immunology,low,3,40.8050,-73.9150,,true,true,true,3800,product_b
@@ -0,0 +1,9 @@
1
+ rep_id,name,base_lat,base_lng,product_expertise,max_weekly_hours,max_daily_calls,available_days
2
+ R001,Alice Park,40.7580,-73.9855,product_a;product_b,40,8,mon;tue;wed;thu;fri
3
+ R002,Bob Torres,40.6892,-73.9505,product_a;product_b,40,8,mon;tue;wed;thu;fri
4
+ R003,Carol Wu,40.7282,-74.0431,product_a;product_b,40,8,mon;tue;wed;thu;fri
5
+ R004,Dan Osei,40.7178,-74.0650,product_a,35,6,mon;tue;wed;thu
6
+ R005,Elena Rossi,40.7440,-74.1690,product_a;product_b,40,8,mon;tue;wed;thu;fri
7
+ R006,Frank Bauer,40.7580,-74.2260,product_b,40,8,mon;tue;wed;thu;fri
8
+ R007,Grace Tanaka,40.8580,-73.8690,product_a;product_b,40,8,mon;tue;wed;thu;fri
9
+ R008,Hank Morrison,40.8128,-73.9291,product_a;product_b,40,8,mon;tue;wed;thu;fri
@@ -0,0 +1,175 @@
1
+ """Geographic utilities: haversine, distance matrix, clustering, routing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+ _EARTH_RADIUS_KM = 6371.0
8
+
9
+
10
+ def haversine(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
11
+ """Great-circle distance in km between two points."""
12
+ lat1, lng1, lat2, lng2 = (math.radians(v) for v in (lat1, lng1, lat2, lng2))
13
+ dlat = lat2 - lat1
14
+ dlng = lng2 - lng1
15
+ a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlng / 2) ** 2
16
+ return 2 * _EARTH_RADIUS_KM * math.asin(math.sqrt(a))
17
+
18
+
19
+ def travel_minutes(km: float, speed_kmh: float = 40.0) -> float:
20
+ """Rough urban travel estimate."""
21
+ if km <= 0:
22
+ return 0.0
23
+ return (km / speed_kmh) * 60.0
24
+
25
+
26
+ def distance_matrix(
27
+ points: list[tuple[float, float]],
28
+ ) -> list[list[float]]:
29
+ """Pairwise haversine distances (km) between points."""
30
+ n = len(points)
31
+ mat = [[0.0] * n for _ in range(n)]
32
+ for i in range(n):
33
+ for j in range(i + 1, n):
34
+ d = haversine(points[i][0], points[i][1], points[j][0], points[j][1])
35
+ mat[i][j] = d
36
+ mat[j][i] = d
37
+ return mat
38
+
39
+
40
+ def centroid(points: list[tuple[float, float]]) -> tuple[float, float]:
41
+ """Arithmetic mean of lat/lng points."""
42
+ if not points:
43
+ return 0.0, 0.0
44
+ lat = sum(p[0] for p in points) / len(points)
45
+ lng = sum(p[1] for p in points) / len(points)
46
+ return lat, lng
47
+
48
+
49
+ def grid_cluster(
50
+ points: list[tuple[float, float, str]],
51
+ target_per_cluster: int = 6,
52
+ ) -> list[list[str]]:
53
+ """Simple grid-based clustering. Each point is (lat, lng, id).
54
+
55
+ Returns a list of clusters, each a list of point IDs.
56
+ """
57
+ if not points:
58
+ return []
59
+
60
+ lats = [p[0] for p in points]
61
+ lngs = [p[1] for p in points]
62
+ min_lat, max_lat = min(lats), max(lats)
63
+ min_lng, max_lng = min(lngs), max(lngs)
64
+
65
+ n = len(points)
66
+ num_clusters = max(1, round(n / target_per_cluster))
67
+ grid_side = max(1, math.ceil(math.sqrt(num_clusters)))
68
+
69
+ lat_step = (max_lat - min_lat + 1e-9) / grid_side
70
+ lng_step = (max_lng - min_lng + 1e-9) / grid_side
71
+
72
+ cells: dict[tuple[int, int], list[str]] = {}
73
+ for lat, lng, pid in points:
74
+ r = min(int((lat - min_lat) / lat_step), grid_side - 1)
75
+ c = min(int((lng - min_lng) / lng_step), grid_side - 1)
76
+ cells.setdefault((r, c), []).append(pid)
77
+
78
+ clusters = list(cells.values())
79
+
80
+ merged: list[list[str]] = []
81
+ for cluster in clusters:
82
+ if len(cluster) < 2 and merged:
83
+ merged[-1].extend(cluster)
84
+ else:
85
+ merged.append(cluster)
86
+
87
+ return merged if merged else [list(cells.values())[0]] if cells else []
88
+
89
+
90
+ def nearest_neighbor_route(
91
+ points: list[tuple[float, float]],
92
+ start: tuple[float, float] | None = None,
93
+ ) -> tuple[list[int], float]:
94
+ """Nearest-neighbor TSP heuristic. Returns (visit_order, total_km)."""
95
+ if not points:
96
+ return [], 0.0
97
+ if len(points) == 1:
98
+ d = haversine(start[0], start[1], points[0][0], points[0][1]) if start else 0.0
99
+ return [0], d
100
+
101
+ n = len(points)
102
+ visited = [False] * n
103
+ order: list[int] = []
104
+ total_km = 0.0
105
+
106
+ if start:
107
+ dists = [haversine(start[0], start[1], p[0], p[1]) for p in points]
108
+ current_idx = min(range(n), key=lambda i: dists[i])
109
+ total_km += dists[current_idx]
110
+ else:
111
+ current_idx = 0
112
+
113
+ visited[current_idx] = True
114
+ order.append(current_idx)
115
+
116
+ for _ in range(n - 1):
117
+ best_dist = float("inf")
118
+ best_idx = -1
119
+ for j in range(n):
120
+ if visited[j]:
121
+ continue
122
+ d = haversine(
123
+ points[current_idx][0],
124
+ points[current_idx][1],
125
+ points[j][0],
126
+ points[j][1],
127
+ )
128
+ if d < best_dist:
129
+ best_dist = d
130
+ best_idx = j
131
+ visited[best_idx] = True
132
+ order.append(best_idx)
133
+ total_km += best_dist
134
+ current_idx = best_idx
135
+
136
+ return order, total_km
137
+
138
+
139
+ def two_opt_improve(
140
+ points: list[tuple[float, float]],
141
+ route: list[int],
142
+ max_iterations: int = 100,
143
+ ) -> tuple[list[int], float]:
144
+ """Improve a route using 2-opt swaps."""
145
+ n = len(route)
146
+ if n < 4:
147
+ total = sum(
148
+ haversine(points[route[i]][0], points[route[i]][1], points[route[i + 1]][0], points[route[i + 1]][1])
149
+ for i in range(n - 1)
150
+ )
151
+ return route, total
152
+
153
+ def route_distance(r: list[int]) -> float:
154
+ return sum(
155
+ haversine(points[r[i]][0], points[r[i]][1], points[r[i + 1]][0], points[r[i + 1]][1])
156
+ for i in range(len(r) - 1)
157
+ )
158
+
159
+ best = list(route)
160
+ best_dist = route_distance(best)
161
+
162
+ for _ in range(max_iterations):
163
+ improved = False
164
+ for i in range(1, n - 1):
165
+ for j in range(i + 1, n):
166
+ new_route = best[:i] + best[i : j + 1][::-1] + best[j + 1 :]
167
+ new_dist = route_distance(new_route)
168
+ if new_dist < best_dist - 0.01:
169
+ best = new_route
170
+ best_dist = new_dist
171
+ improved = True
172
+ if not improved:
173
+ break
174
+
175
+ return best, best_dist