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,84 @@
1
+ """update_account — simulates a CRM writeback of enrichment results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class UpdateAccountArgs(BaseModel):
11
+ account_id: str = Field(description="Account ID to update (e.g. 'HCP-AU-001')")
12
+ status: str = Field(
13
+ description="New enrichment status: 'enriched' (profile complete), 'failed' (enrichment unsuccessful), or 'pending' (reset)"
14
+ )
15
+ profile_json: str | None = Field(
16
+ default=None,
17
+ description=(
18
+ "The full HcpProfile or HcoProfile as a JSON string. Required when "
19
+ "status is 'enriched'. This is the structured profile output from the "
20
+ "intelligence workflow."
21
+ ),
22
+ )
23
+
24
+
25
+ TOOL: dict[str, Any] = {
26
+ "name": "update_account",
27
+ "description": (
28
+ "Write enrichment results back to an account, simulating a CRM writeback. "
29
+ "Pass the account ID, the new status ('enriched' or 'failed'), and the "
30
+ "full profile JSON. The profile is stored alongside the account and can be "
31
+ "retrieved later via get_account. Use this after completing the HCP/HCO "
32
+ "profiling workflow to persist the results."
33
+ ),
34
+ "args": UpdateAccountArgs,
35
+ }
36
+
37
+
38
+ def handle(arguments: dict[str, Any]) -> list[dict[str, Any]]:
39
+ import json
40
+
41
+ from .._crm_store import get_account_by_id, write_enrichment
42
+
43
+ account_id = arguments["account_id"]
44
+ status = arguments["status"]
45
+ profile_json = arguments.get("profile_json")
46
+
47
+ record = get_account_by_id(account_id)
48
+ if record is None:
49
+ return [{"type": "text", "text": json.dumps({"error": f"Account '{account_id}' not found"})}]
50
+
51
+ if status not in ("enriched", "failed", "pending"):
52
+ return [
53
+ {
54
+ "type": "text",
55
+ "text": json.dumps({"error": f"Invalid status '{status}'. Use 'enriched', 'failed', or 'pending'."}),
56
+ }
57
+ ]
58
+
59
+ if status == "enriched" and not profile_json:
60
+ return [{"type": "text", "text": json.dumps({"error": "profile_json is required when status is 'enriched'"})}]
61
+
62
+ if profile_json:
63
+ try:
64
+ json.loads(profile_json)
65
+ except json.JSONDecodeError as e:
66
+ return [{"type": "text", "text": json.dumps({"error": f"Invalid profile_json: {e}"})}]
67
+
68
+ write_enrichment(account_id, profile_json or "", status)
69
+
70
+ return [
71
+ {
72
+ "type": "text",
73
+ "text": json.dumps(
74
+ {
75
+ "success": True,
76
+ "account_id": account_id,
77
+ "name": record["name"],
78
+ "status": status,
79
+ "message": f"Account {account_id} ({record['name']}) updated to '{status}'.",
80
+ },
81
+ indent=2,
82
+ ),
83
+ }
84
+ ]
@@ -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
+ "Next-Best-Engagement recommends which HCPs to engage, through which "
9
+ "channel, by which rep, subject to capacity, consent, and frequency "
10
+ "constraints. Load an HCP universe (CSV or built-in fixtures), then "
11
+ "generate an optimised engagement plan."
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,400 @@
1
+ """Two-phase greedy optimizer for engagement plan allocation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from datetime import date, timedelta
7
+ from typing import Any
8
+
9
+ from ._scoring import score_hcp
10
+ from .models import (
11
+ ConstraintConfig,
12
+ EngagementPlan,
13
+ PlanMetrics,
14
+ PlannedEngagement,
15
+ RepUtilization,
16
+ TierCoverage,
17
+ UnassignedHCP,
18
+ UniverseRow,
19
+ )
20
+
21
+
22
+ def generate_plan(
23
+ universe: list[UniverseRow],
24
+ rep_info: dict[str, dict[str, Any]],
25
+ config: ConstraintConfig,
26
+ today: date | None = None,
27
+ ) -> EngagementPlan:
28
+ """Generate an optimized engagement plan."""
29
+ today = today or date.today()
30
+ period_start = today
31
+ period_end = today + timedelta(days=config.period_days)
32
+
33
+ weeks_in_period = config.period_days / 7
34
+ rep_remaining: dict[str, int] = {
35
+ rep_id: int(info["capacity"] * weeks_in_period) for rep_id, info in rep_info.items()
36
+ }
37
+
38
+ hcp_action_counts: dict[str, list[str]] = {}
39
+ tier_counts: dict[str, int] = {}
40
+ for hcp in universe:
41
+ tier_counts[hcp.tier] = tier_counts.get(hcp.tier, 0) + 1
42
+
43
+ tier_coverage_actual: dict[str, float] = {t: 0.0 for t in tier_counts}
44
+
45
+ scored = []
46
+ for hcp in universe:
47
+ s, rationale = score_hcp(hcp, config, tier_coverage_actual, today)
48
+ scored.append((s, hcp, rationale))
49
+ scored.sort(key=lambda x: x[0], reverse=True)
50
+
51
+ engagements: list[PlannedEngagement] = []
52
+ unassigned: list[UnassignedHCP] = []
53
+ assigned_hcps: set[str] = set()
54
+ no_action_reasons: dict[str, int] = {}
55
+
56
+ target_map = {
57
+ "A": config.tier_a_coverage_pct,
58
+ "B": config.tier_b_coverage_pct,
59
+ "C": config.tier_c_coverage_pct,
60
+ "D": config.tier_d_coverage_pct,
61
+ }
62
+
63
+ # Phase 1: Coverage pass
64
+ for tier in ["A", "B", "C", "D"]:
65
+ target_count = math.ceil(target_map[tier] * tier_counts.get(tier, 0))
66
+ tier_assigned = 0
67
+
68
+ tier_scored = [(s, hcp, r) for s, hcp, r in scored if hcp.tier == tier and hcp.hcp_id not in assigned_hcps]
69
+ tier_scored.sort(key=lambda x: x[0], reverse=True)
70
+
71
+ for s, hcp, rationale in tier_scored:
72
+ if tier_assigned >= target_count:
73
+ break
74
+
75
+ result = _try_assign(
76
+ hcp,
77
+ s,
78
+ rationale,
79
+ config,
80
+ hcp_action_counts,
81
+ rep_remaining,
82
+ rep_info,
83
+ period_start,
84
+ period_end,
85
+ today,
86
+ )
87
+ if result is None:
88
+ _record_unassigned(hcp, "no_rep_capacity", unassigned, no_action_reasons)
89
+ assigned_hcps.add(hcp.hcp_id)
90
+ continue
91
+ action, engagement = result
92
+ if action == "no_action":
93
+ _record_unassigned(hcp, _no_action_reason(hcp, config, today), unassigned, no_action_reasons)
94
+ assigned_hcps.add(hcp.hcp_id)
95
+ continue
96
+
97
+ engagements.append(engagement)
98
+ assigned_hcps.add(hcp.hcp_id)
99
+ rep_remaining[engagement.rep_id] -= 1
100
+ hcp_action_counts.setdefault(hcp.hcp_id, []).append(action)
101
+ tier_assigned += 1
102
+
103
+ # Re-score remaining HCPs with updated coverage for Phase 2
104
+ tier_assigned: dict[str, int] = {}
105
+ for e in engagements:
106
+ tier_assigned[e.tier] = tier_assigned.get(e.tier, 0) + 1
107
+ tier_coverage_actual = {
108
+ tier: tier_assigned.get(tier, 0) / count for tier, count in tier_counts.items() if count > 0
109
+ }
110
+
111
+ # Phase 2: Opportunity fill
112
+ remaining = []
113
+ for hcp in universe:
114
+ if hcp.hcp_id not in assigned_hcps:
115
+ s, rationale = score_hcp(hcp, config, tier_coverage_actual, today)
116
+ remaining.append((s, hcp, rationale))
117
+ remaining.sort(key=lambda x: x[0], reverse=True)
118
+
119
+ for s, hcp, rationale in remaining:
120
+ if s < config.no_action_threshold:
121
+ _record_unassigned(hcp, "below_threshold", unassigned, no_action_reasons)
122
+ continue
123
+
124
+ result = _try_assign(
125
+ hcp,
126
+ s,
127
+ rationale,
128
+ config,
129
+ hcp_action_counts,
130
+ rep_remaining,
131
+ rep_info,
132
+ period_start,
133
+ period_end,
134
+ today,
135
+ )
136
+ if result is None:
137
+ _record_unassigned(hcp, "no_rep_capacity", unassigned, no_action_reasons)
138
+ continue
139
+ action, engagement = result
140
+ if action == "no_action":
141
+ _record_unassigned(hcp, _no_action_reason(hcp, config, today), unassigned, no_action_reasons)
142
+ continue
143
+
144
+ engagements.append(engagement)
145
+ assigned_hcps.add(hcp.hcp_id)
146
+ rep_remaining[engagement.rep_id] -= 1
147
+ hcp_action_counts.setdefault(hcp.hcp_id, []).append(action)
148
+
149
+ metrics = _build_metrics(
150
+ universe,
151
+ engagements,
152
+ rep_info,
153
+ rep_remaining,
154
+ config,
155
+ no_action_reasons,
156
+ weeks_in_period,
157
+ tier_counts,
158
+ target_map,
159
+ )
160
+
161
+ return EngagementPlan(
162
+ period_start=period_start,
163
+ period_end=period_end,
164
+ generated_at=today.isoformat(),
165
+ engagements=engagements,
166
+ unassigned=unassigned,
167
+ metrics=metrics,
168
+ )
169
+
170
+
171
+ # ---- internal helpers ----
172
+
173
+
174
+ def _try_assign(
175
+ hcp: UniverseRow,
176
+ score: float,
177
+ rationale: str,
178
+ config: ConstraintConfig,
179
+ hcp_action_counts: dict[str, list[str]],
180
+ rep_remaining: dict[str, int],
181
+ rep_info: dict[str, dict[str, Any]],
182
+ period_start: date,
183
+ period_end: date,
184
+ today: date,
185
+ ) -> tuple[str, PlannedEngagement] | None:
186
+ """Try to assign an action to an HCP. Returns None if no rep has capacity."""
187
+ rep_id = _pick_rep(hcp, rep_remaining, rep_info)
188
+ if rep_id is None:
189
+ return None
190
+
191
+ action = _select_action(hcp, config, hcp_action_counts.get(hcp.hcp_id, []), today)
192
+ engagement = _create_engagement(
193
+ hcp,
194
+ action,
195
+ rep_id,
196
+ rep_info,
197
+ score,
198
+ rationale,
199
+ period_start,
200
+ period_end,
201
+ )
202
+ return action, engagement
203
+
204
+
205
+ def _select_action(
206
+ hcp: UniverseRow,
207
+ config: ConstraintConfig,
208
+ prior_actions: list[str],
209
+ today: date,
210
+ ) -> str:
211
+ """Rule cascade: pick the best action for this HCP."""
212
+ visit_count = sum(1 for a in prior_actions if a == "in_person_visit")
213
+ email_count = sum(1 for a in prior_actions if a == "approved_email")
214
+
215
+ last_visit = hcp.last_visit_date
216
+ days_since_visit = (today - last_visit).days if last_visit else 999
217
+
218
+ # Rule 0: Min gap — skip if contacted too recently
219
+ most_recent = max(
220
+ (d for d in [hcp.last_visit_date, hcp.last_email_date, hcp.last_meeting_date] if d),
221
+ default=None,
222
+ )
223
+ if most_recent and (today - most_recent).days < config.min_gap_days:
224
+ return "no_action"
225
+
226
+ # Rule 1: High-tier + overdue for visit + phone consent → in-person visit
227
+ if hcp.tier in ("A", "B") and days_since_visit >= 45 and hcp.consent_phone:
228
+ if visit_count < config.max_visits_per_hcp:
229
+ return "in_person_visit"
230
+
231
+ # Rule 2: Recently visited + email consent → email follow-up
232
+ if days_since_visit < 30 and hcp.consent_email:
233
+ if email_count < config.max_emails_per_hcp:
234
+ return "approved_email"
235
+
236
+ # Rule 3: Not recently visited + phone consent → remote meeting
237
+ if days_since_visit >= 30 and hcp.consent_phone:
238
+ if visit_count < config.max_visits_per_hcp:
239
+ return "remote_meeting"
240
+
241
+ # Rule 4: Email fallback
242
+ if hcp.consent_email and email_count < config.max_emails_per_hcp:
243
+ return "approved_email"
244
+
245
+ # Rule 5: Phone fallback
246
+ if hcp.consent_phone and visit_count < config.max_visits_per_hcp:
247
+ return "in_person_visit"
248
+
249
+ return "no_action"
250
+
251
+
252
+ def _pick_rep(
253
+ hcp: UniverseRow,
254
+ rep_remaining: dict[str, int],
255
+ rep_info: dict[str, dict[str, Any]],
256
+ ) -> str | None:
257
+ """Pick a rep with capacity — prefer the assigned rep, then least-loaded in territory."""
258
+ if hcp.rep_id in rep_remaining and rep_remaining[hcp.rep_id] > 0:
259
+ return hcp.rep_id
260
+
261
+ candidates = [
262
+ (rep_id, rep_remaining.get(rep_id, 0))
263
+ for rep_id, info in rep_info.items()
264
+ if hcp.territory_id in info["territories"] and rep_remaining.get(rep_id, 0) > 0
265
+ ]
266
+ if not candidates:
267
+ return None
268
+ candidates.sort(key=lambda x: x[1], reverse=True)
269
+ return candidates[0][0]
270
+
271
+
272
+ def _create_engagement(
273
+ hcp: UniverseRow,
274
+ action: str,
275
+ rep_id: str,
276
+ rep_info: dict[str, dict[str, Any]],
277
+ score: float,
278
+ rationale: str,
279
+ period_start: date,
280
+ period_end: date,
281
+ ) -> PlannedEngagement:
282
+ if score >= 0.8:
283
+ priority = 1
284
+ elif score >= 0.6:
285
+ priority = 2
286
+ elif score >= 0.4:
287
+ priority = 3
288
+ elif score >= 0.2:
289
+ priority = 4
290
+ else:
291
+ priority = 5
292
+
293
+ rep_name = rep_info.get(rep_id, {}).get("name", rep_id)
294
+
295
+ data: dict[str, Any] = {
296
+ "hcp_id": hcp.hcp_id,
297
+ "hcp_name": hcp.hcp_name,
298
+ "specialty": hcp.specialty,
299
+ "tier": hcp.tier,
300
+ "territory_id": hcp.territory_id,
301
+ "rep_id": rep_id,
302
+ "rep_name": rep_name,
303
+ "action_type": action,
304
+ "priority": priority,
305
+ "score": round(score, 3),
306
+ "suggested_window_start": period_start,
307
+ "suggested_window_end": period_end,
308
+ "rationale": rationale,
309
+ }
310
+ data.update(hcp.model_extra)
311
+ return PlannedEngagement.model_validate(data)
312
+
313
+
314
+ def _no_action_reason(hcp: UniverseRow, config: ConstraintConfig, today: date) -> str:
315
+ """Determine why an HCP got no_action."""
316
+ most_recent = max(
317
+ (d for d in [hcp.last_visit_date, hcp.last_email_date, hcp.last_meeting_date] if d),
318
+ default=None,
319
+ )
320
+ if most_recent and (today - most_recent).days < config.min_gap_days:
321
+ return "too_recent"
322
+ if not hcp.consent_email and not hcp.consent_phone:
323
+ return "no_consent"
324
+ return "channels_exhausted"
325
+
326
+
327
+ def _record_unassigned(
328
+ hcp: UniverseRow,
329
+ reason: str,
330
+ unassigned: list[UnassignedHCP],
331
+ no_action_reasons: dict[str, int],
332
+ ) -> None:
333
+ no_action_reasons[reason] = no_action_reasons.get(reason, 0) + 1
334
+ unassigned.append(UnassignedHCP(hcp_id=hcp.hcp_id, hcp_name=hcp.hcp_name, tier=hcp.tier, reason=reason))
335
+
336
+
337
+ def _build_metrics(
338
+ universe: list[UniverseRow],
339
+ engagements: list[PlannedEngagement],
340
+ rep_info: dict[str, dict[str, Any]],
341
+ rep_remaining: dict[str, int],
342
+ config: ConstraintConfig,
343
+ no_action_reasons: dict[str, int],
344
+ weeks_in_period: float,
345
+ tier_counts: dict[str, int],
346
+ target_map: dict[str, float],
347
+ ) -> PlanMetrics:
348
+ total_eligible = len(universe)
349
+ total_planned = len(engagements)
350
+
351
+ # Coverage by tier
352
+ tier_planned: dict[str, int] = {}
353
+ for e in engagements:
354
+ tier_planned[e.tier] = tier_planned.get(e.tier, 0) + 1
355
+
356
+ coverage_by_tier: dict[str, TierCoverage] = {}
357
+ for tier in sorted(tier_counts.keys()):
358
+ total = tier_counts[tier]
359
+ planned = tier_planned.get(tier, 0)
360
+ actual_pct = planned / total if total else 0.0
361
+ coverage_by_tier[tier] = TierCoverage(
362
+ target_pct=target_map.get(tier, 0.5),
363
+ actual_pct=round(actual_pct, 3),
364
+ total=total,
365
+ planned=planned,
366
+ gap=max(0, math.ceil(target_map.get(tier, 0.5) * total) - planned),
367
+ )
368
+
369
+ # Rep utilization
370
+ rep_utilization = []
371
+ for rep_id, info in sorted(rep_info.items()):
372
+ capacity = int(info["capacity"] * weeks_in_period)
373
+ assigned = capacity - rep_remaining.get(rep_id, 0)
374
+ rep_utilization.append(
375
+ RepUtilization(
376
+ rep_id=rep_id,
377
+ rep_name=info["name"],
378
+ capacity=capacity,
379
+ assigned=assigned,
380
+ utilization_pct=round(assigned / capacity, 3) if capacity else 0.0,
381
+ )
382
+ )
383
+
384
+ # Channel mix
385
+ channel_mix: dict[str, int] = {}
386
+ for e in engagements:
387
+ channel_mix[e.action_type] = channel_mix.get(e.action_type, 0) + 1
388
+
389
+ coverage_pct = total_planned / total_eligible if total_eligible else 0.0
390
+
391
+ return PlanMetrics(
392
+ total_eligible=total_eligible,
393
+ total_planned=total_planned,
394
+ coverage_pct=round(coverage_pct, 3),
395
+ coverage_by_tier=coverage_by_tier,
396
+ rep_utilization=rep_utilization,
397
+ channel_mix=channel_mix,
398
+ no_action_count=sum(no_action_reasons.values()),
399
+ no_action_reasons=no_action_reasons,
400
+ )
@@ -0,0 +1,149 @@
1
+ """Output renderers — CSV file export and JSON serialisation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import io
7
+ from datetime import date
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from shared.filesystem import atomic_write_text, ensure_private_dir
12
+
13
+ from .models import EngagementPlan
14
+
15
+
16
+ def render_csv(plan: EngagementPlan, output_dir: str) -> dict[str, str]:
17
+ """Write the plan to CSV files. Returns paths of written files."""
18
+ from datetime import datetime, timezone
19
+
20
+ out = ensure_private_dir(Path(output_dir))
21
+
22
+ ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
23
+ engagements_path = out / f"engagements_{ts}.csv"
24
+ summary_path = out / f"plan_summary_{ts}.csv"
25
+
26
+ _write_engagements_csv(plan, engagements_path)
27
+ _write_summary_csv(plan, summary_path)
28
+
29
+ return {
30
+ "engagements_csv": str(engagements_path),
31
+ "summary_csv": str(summary_path),
32
+ }
33
+
34
+
35
+ def render_json(plan: EngagementPlan) -> str:
36
+ """Serialise the plan to a JSON string."""
37
+ return plan.model_dump_json(indent=2)
38
+
39
+
40
+ _DEFAULT_ENGAGEMENT_COLUMNS = [
41
+ "hcp_id",
42
+ "hcp_name",
43
+ "specialty",
44
+ "tier",
45
+ "territory_id",
46
+ "rep_id",
47
+ "rep_name",
48
+ "action_type",
49
+ "priority",
50
+ "score",
51
+ "suggested_window_start",
52
+ "suggested_window_end",
53
+ "rationale",
54
+ ]
55
+
56
+
57
+ def _write_engagements_csv(plan: EngagementPlan, path: Path) -> None:
58
+ buffer = io.StringIO(newline="")
59
+ if not plan.engagements:
60
+ writer = csv.DictWriter(buffer, fieldnames=_DEFAULT_ENGAGEMENT_COLUMNS)
61
+ writer.writeheader()
62
+ atomic_write_text(path, buffer.getvalue())
63
+ return
64
+
65
+ rows = [_engagement_to_row(e) for e in plan.engagements]
66
+ fieldnames = list(rows[0].keys())
67
+
68
+ writer = csv.DictWriter(buffer, fieldnames=fieldnames)
69
+ writer.writeheader()
70
+ writer.writerows(rows)
71
+ atomic_write_text(path, buffer.getvalue())
72
+
73
+
74
+ def _write_summary_csv(plan: EngagementPlan, path: Path) -> None:
75
+ m = plan.metrics
76
+ rows: list[dict[str, Any]] = []
77
+
78
+ rows.append({"section": "overview", "metric": "total_eligible", "value": m.total_eligible})
79
+ rows.append({"section": "overview", "metric": "total_planned", "value": m.total_planned})
80
+ rows.append({"section": "overview", "metric": "coverage_pct", "value": m.coverage_pct})
81
+ rows.append({"section": "overview", "metric": "no_action_count", "value": m.no_action_count})
82
+
83
+ for tier, cov in sorted(m.coverage_by_tier.items()):
84
+ rows.append(
85
+ {
86
+ "section": f"tier_{tier}",
87
+ "metric": "target_pct",
88
+ "value": cov.target_pct,
89
+ }
90
+ )
91
+ rows.append(
92
+ {
93
+ "section": f"tier_{tier}",
94
+ "metric": "actual_pct",
95
+ "value": cov.actual_pct,
96
+ }
97
+ )
98
+ rows.append({"section": f"tier_{tier}", "metric": "total", "value": cov.total})
99
+ rows.append({"section": f"tier_{tier}", "metric": "planned", "value": cov.planned})
100
+ rows.append({"section": f"tier_{tier}", "metric": "gap", "value": cov.gap})
101
+
102
+ for ru in m.rep_utilization:
103
+ rows.append(
104
+ {
105
+ "section": f"rep_{ru.rep_id}",
106
+ "metric": "capacity",
107
+ "value": ru.capacity,
108
+ }
109
+ )
110
+ rows.append(
111
+ {
112
+ "section": f"rep_{ru.rep_id}",
113
+ "metric": "assigned",
114
+ "value": ru.assigned,
115
+ }
116
+ )
117
+ rows.append(
118
+ {
119
+ "section": f"rep_{ru.rep_id}",
120
+ "metric": "utilization_pct",
121
+ "value": ru.utilization_pct,
122
+ }
123
+ )
124
+
125
+ for channel, count in sorted(m.channel_mix.items()):
126
+ rows.append({"section": "channel_mix", "metric": channel, "value": count})
127
+
128
+ buffer = io.StringIO(newline="")
129
+ writer = csv.DictWriter(buffer, fieldnames=["section", "metric", "value"])
130
+ writer.writeheader()
131
+ writer.writerows({key: _safe_csv_cell(value) for key, value in row.items()} for row in rows)
132
+ atomic_write_text(path, buffer.getvalue())
133
+
134
+
135
+ def _engagement_to_row(e: Any) -> dict[str, Any]:
136
+ """Convert a PlannedEngagement to a flat dict for CSV, including extra columns."""
137
+ data = e.model_dump()
138
+ for key in ("suggested_window_start", "suggested_window_end"):
139
+ val = data.get(key)
140
+ if isinstance(val, date):
141
+ data[key] = val.isoformat()
142
+ return {key: _safe_csv_cell(value) for key, value in data.items()}
143
+
144
+
145
+ def _safe_csv_cell(value: Any) -> Any:
146
+ """Prevent spreadsheet applications from interpreting exported text as a formula."""
147
+ if isinstance(value, str) and value.startswith(("=", "+", "-", "@", "\t", "\r", "\n")):
148
+ return "'" + value
149
+ return value