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,82 @@
1
+ """Weighted linear scoring engine for HCP engagement opportunities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date
6
+
7
+ from .models import ConstraintConfig, UniverseRow
8
+
9
+
10
+ def score_hcp(
11
+ hcp: UniverseRow,
12
+ config: ConstraintConfig,
13
+ tier_coverage: dict[str, float],
14
+ today: date,
15
+ ) -> tuple[float, str]:
16
+ """Score an HCP and return (score, rationale_string)."""
17
+ w = config.weights
18
+ factors: list[tuple[str, float, float]] = []
19
+
20
+ # 1. Recency gap — how overdue is this HCP?
21
+ target_interval = config.period_days
22
+ last_touch = _most_recent_touch(hcp)
23
+ if last_touch:
24
+ days_gap = (today - last_touch).days
25
+ recency = min(days_gap / max(target_interval, 1), 1.0)
26
+ else:
27
+ recency = 1.0
28
+ factors.append(("recency_gap", recency, recency * w.recency_gap))
29
+
30
+ # 2. Tier value
31
+ tier_scores = {"A": 1.0, "B": 0.7, "C": 0.4, "D": 0.15}
32
+ tier_val = tier_scores.get(hcp.tier, 0.5)
33
+ factors.append(("tier_value", tier_val, tier_val * w.tier_value))
34
+
35
+ # 3. Engagement velocity — cold HCPs need re-engagement
36
+ total_90d = hcp.visits_last_90d + hcp.emails_last_90d
37
+ if total_90d == 0:
38
+ velocity = 0.8
39
+ elif total_90d <= 2:
40
+ velocity = 0.5
41
+ else:
42
+ velocity = 0.3
43
+ factors.append(("engagement_velocity", velocity, velocity * w.engagement_velocity))
44
+
45
+ # 4. Channel freshness — penalise single-channel repetition
46
+ touches = [
47
+ (hcp.last_visit_date, "visit"),
48
+ (hcp.last_email_date, "email"),
49
+ (hcp.last_meeting_date, "meeting"),
50
+ ]
51
+ recent_channels = [ch for d, ch in touches if d and (today - d).days <= 90]
52
+ if len(recent_channels) <= 1:
53
+ freshness = 0.8 if recent_channels else 1.0
54
+ else:
55
+ unique = len(set(recent_channels))
56
+ freshness = unique / len(recent_channels)
57
+ factors.append(("channel_freshness", freshness, freshness * w.channel_freshness))
58
+
59
+ # 5. Coverage debt — how far is this tier from its target?
60
+ target_map = {
61
+ "A": config.tier_a_coverage_pct,
62
+ "B": config.tier_b_coverage_pct,
63
+ "C": config.tier_c_coverage_pct,
64
+ "D": config.tier_d_coverage_pct,
65
+ }
66
+ target = target_map.get(hcp.tier, 0.5)
67
+ actual = tier_coverage.get(hcp.tier, 0.0)
68
+ debt = max(0.0, target - actual) / max(target, 0.01)
69
+ factors.append(("coverage_debt", debt, debt * w.coverage_debt))
70
+
71
+ total_score = min(max(sum(wt for _, _, wt in factors), 0.0), 1.0)
72
+
73
+ factors.sort(key=lambda f: f[2], reverse=True)
74
+ parts = [f"{name}={raw:.2f} (w={weighted:.2f})" for name, raw, weighted in factors if weighted > 0.01]
75
+ rationale = f"Tier {hcp.tier}, score {total_score:.2f}: " + ", ".join(parts)
76
+
77
+ return total_score, rationale
78
+
79
+
80
+ def _most_recent_touch(hcp: UniverseRow) -> date | None:
81
+ dates = [d for d in (hcp.last_visit_date, hcp.last_email_date, hcp.last_meeting_date) if d]
82
+ return max(dates) if dates else None
@@ -0,0 +1,145 @@
1
+ """In-memory universe store for HCP engagement data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from .models import UniverseRow
10
+
11
+ if TYPE_CHECKING:
12
+ from .models import EngagementPlan
13
+
14
+ _universe: list[UniverseRow] = []
15
+ _rep_info: dict[str, dict[str, Any]] = {}
16
+ _last_plan: EngagementPlan | None = None
17
+
18
+
19
+ def load_fixture() -> list[UniverseRow]:
20
+ """Load the built-in sample universe CSV."""
21
+ fixture_path = Path(__file__).parent / "fixtures" / "sample_universe.csv"
22
+ return load_csv(str(fixture_path))
23
+
24
+
25
+ def load_csv(file_path: str) -> list[UniverseRow]:
26
+ """Load a CSV file into the universe store."""
27
+ global _universe, _rep_info
28
+
29
+ rows: list[UniverseRow] = []
30
+ with open(file_path, newline="") as f:
31
+ reader = csv.DictReader(f)
32
+ for raw in reader:
33
+ _coerce_row(raw)
34
+ rows.append(UniverseRow.model_validate(raw))
35
+
36
+ _universe = rows
37
+ _build_rep_index()
38
+ return _universe
39
+
40
+
41
+ def _coerce_row(raw: dict[str, Any]) -> None:
42
+ """Convert string CSV values to the types UniverseRow expects."""
43
+ for date_field in ("last_visit_date", "last_email_date", "last_meeting_date"):
44
+ if date_field in raw and not raw[date_field]:
45
+ raw[date_field] = None
46
+ for bool_field in ("consent_email", "consent_phone"):
47
+ if bool_field in raw:
48
+ raw[bool_field] = raw[bool_field].lower() in ("true", "1", "yes")
49
+ for int_field in ("rep_max_visits_per_week", "visits_last_90d", "emails_last_90d"):
50
+ if int_field in raw and raw[int_field]:
51
+ raw[int_field] = int(raw[int_field])
52
+ elif int_field in raw:
53
+ raw[int_field] = 0
54
+
55
+ _coerce_extras(raw)
56
+
57
+
58
+ _KNOWN_FIELDS = set(UniverseRow.model_fields.keys())
59
+
60
+
61
+ def _coerce_extras(raw: dict[str, Any]) -> None:
62
+ """Best-effort type coercion for extra (pass-through) columns."""
63
+ for key in raw:
64
+ if key in _KNOWN_FIELDS:
65
+ continue
66
+ val = raw[key]
67
+ if not isinstance(val, str) or not val:
68
+ continue
69
+ low = val.lower()
70
+ if low in ("true", "false"):
71
+ raw[key] = low == "true"
72
+ continue
73
+ try:
74
+ raw[key] = int(val)
75
+ except ValueError:
76
+ try:
77
+ raw[key] = float(val)
78
+ except ValueError:
79
+ pass
80
+
81
+
82
+ def _build_rep_index() -> None:
83
+ """Build a rep lookup from the loaded universe.
84
+
85
+ If different rows list different capacities for the same rep, the
86
+ maximum is used.
87
+ """
88
+ global _rep_info
89
+ _rep_info = {}
90
+ for row in _universe:
91
+ if row.rep_id not in _rep_info:
92
+ _rep_info[row.rep_id] = {
93
+ "name": row.rep_name or row.rep_id,
94
+ "territories": set(),
95
+ "capacity": row.rep_max_visits_per_week,
96
+ }
97
+ else:
98
+ _rep_info[row.rep_id]["capacity"] = max(_rep_info[row.rep_id]["capacity"], row.rep_max_visits_per_week)
99
+ _rep_info[row.rep_id]["territories"].add(row.territory_id)
100
+
101
+
102
+ def store_plan(plan: EngagementPlan) -> None:
103
+ global _last_plan
104
+ _last_plan = plan
105
+
106
+
107
+ def get_last_plan() -> EngagementPlan | None:
108
+ return _last_plan
109
+
110
+
111
+ def get_universe() -> list[UniverseRow]:
112
+ return list(_universe)
113
+
114
+
115
+ def get_rep_info() -> dict[str, dict[str, Any]]:
116
+ return dict(_rep_info)
117
+
118
+
119
+ def get_summary() -> dict[str, Any]:
120
+ """Return summary stats of the loaded universe."""
121
+ if not _universe:
122
+ return {"loaded": False, "hcp_count": 0}
123
+
124
+ tiers: dict[str, int] = {}
125
+ territories: set[str] = set()
126
+ specialties: set[str] = set()
127
+ for row in _universe:
128
+ tiers[row.tier] = tiers.get(row.tier, 0) + 1
129
+ territories.add(row.territory_id)
130
+ specialties.add(row.specialty)
131
+
132
+ extra_columns = []
133
+ if _universe:
134
+ extra_columns = sorted(_universe[0].model_extra.keys())
135
+
136
+ return {
137
+ "loaded": True,
138
+ "hcp_count": len(_universe),
139
+ "rep_count": len(_rep_info),
140
+ "territory_count": len(territories),
141
+ "territories": sorted(territories),
142
+ "specialties": sorted(specialties),
143
+ "tier_distribution": dict(sorted(tiers.items())),
144
+ "extra_columns": extra_columns,
145
+ }
@@ -0,0 +1,81 @@
1
+ hcp_id,hcp_name,specialty,tier,territory_id,rep_id,rep_name,rep_max_visits_per_week,consent_email,consent_phone,last_visit_date,last_email_date,last_meeting_date,visits_last_90d,emails_last_90d,kol_flag,affiliation
2
+ HCP-E-001,Sarah Chen,Oncology,A,EAST,REP-E-01,James Rivera,20,true,true,2026-07-10,2026-08-01,,2,3,true,Memorial Cancer Center
3
+ HCP-E-002,Michael Torres,Oncology,A,EAST,REP-E-01,James Rivera,20,true,true,2026-06-15,2026-07-20,,1,2,true,Memorial Cancer Center
4
+ HCP-E-003,Priya Sharma,Cardiology,A,EAST,REP-E-02,Linda Park,20,true,true,,,2026-07-05,0,0,false,City Heart Institute
5
+ HCP-E-004,David Okafor,Cardiology,A,EAST,REP-E-02,Linda Park,20,true,true,2026-08-10,2026-08-12,,3,4,true,City Heart Institute
6
+ HCP-E-005,Rachel Kim,Oncology,B,EAST,REP-E-01,James Rivera,20,true,true,2026-07-25,2026-08-05,,2,2,false,St. Luke's Hospital
7
+ HCP-E-006,Thomas Wright,Cardiology,B,EAST,REP-E-01,James Rivera,20,true,false,2026-06-01,,,1,0,false,Eastside Medical Group
8
+ HCP-E-007,Amara Osei,Oncology,B,EAST,REP-E-02,Linda Park,20,true,true,2026-05-20,2026-06-30,,1,1,false,University Hospital East
9
+ HCP-E-008,Jennifer Liu,Cardiology,B,EAST,REP-E-02,Linda Park,20,false,true,2026-07-30,,,2,0,false,Regional Cardiac Center
10
+ HCP-E-009,Robert Patel,Oncology,B,EAST,REP-E-01,James Rivera,20,true,true,,2026-07-15,,0,1,false,Community Oncology Associates
11
+ HCP-E-010,Fatima Al-Hassan,Cardiology,B,EAST,REP-E-02,Linda Park,20,true,true,2026-04-10,2026-05-25,,1,2,true,City Heart Institute
12
+ HCP-E-011,Andrew Nowak,Oncology,C,EAST,REP-E-01,James Rivera,20,true,true,2026-08-05,2026-08-10,,1,1,false,St. Luke's Hospital
13
+ HCP-E-012,Maria Santos,Cardiology,C,EAST,REP-E-01,James Rivera,20,true,true,,,,0,0,false,Eastside Medical Group
14
+ HCP-E-013,Kevin Zhao,Oncology,C,EAST,REP-E-02,Linda Park,20,true,false,2026-06-20,,,1,0,false,Memorial Cancer Center
15
+ HCP-E-014,Laura Becker,Cardiology,C,EAST,REP-E-02,Linda Park,20,true,true,2026-03-15,2026-07-01,,1,1,false,Regional Cardiac Center
16
+ HCP-E-015,Hassan Yusuf,Oncology,C,EAST,REP-E-01,James Rivera,20,false,false,,,,0,0,false,Community Oncology Associates
17
+ HCP-E-016,Emily Strand,Cardiology,C,EAST,REP-E-02,Linda Park,20,true,true,2026-07-15,,,1,0,false,City Heart Institute
18
+ HCP-E-017,Daniel Cho,Oncology,D,EAST,REP-E-01,James Rivera,20,true,true,,2026-06-10,,0,1,false,St. Luke's Hospital
19
+ HCP-E-018,Olivia Martin,Cardiology,D,EAST,REP-E-01,James Rivera,20,true,false,2026-05-01,,,1,0,false,Eastside Medical Group
20
+ HCP-E-019,Raj Gupta,Oncology,D,EAST,REP-E-02,Linda Park,20,true,true,,,,0,0,false,University Hospital East
21
+ HCP-E-020,Susan O'Neill,Cardiology,D,EAST,REP-E-02,Linda Park,20,false,true,2026-07-20,,,1,0,false,Regional Cardiac Center
22
+ HCP-W-001,James Nakamura,Neurology,A,WEST,REP-W-01,Carlos Mendez,20,true,true,2026-06-25,2026-07-30,,2,2,true,Pacific Neuroscience Institute
23
+ HCP-W-002,Lisa Johansson,Immunology,A,WEST,REP-W-01,Carlos Mendez,20,true,true,2026-07-05,2026-08-08,,2,3,true,West Coast Immunology Center
24
+ HCP-W-003,Wei Zhang,Neurology,A,WEST,REP-W-02,Samantha Brooks,20,true,true,,2026-07-12,,0,1,false,Harbor Medical Center
25
+ HCP-W-004,Patricia Reyes,Immunology,A,WEST,REP-W-02,Samantha Brooks,20,true,true,2026-08-01,2026-08-15,,3,2,true,University Hospital West
26
+ HCP-W-005,Mark Thompson,Neurology,B,WEST,REP-W-01,Carlos Mendez,20,true,true,2026-05-10,2026-06-20,,1,1,false,Pacific Neuroscience Institute
27
+ HCP-W-006,Anna Kowalski,Immunology,B,WEST,REP-W-01,Carlos Mendez,20,true,false,2026-07-18,,,2,0,false,Coastal Rheumatology Group
28
+ HCP-W-007,Bryan Jackson,Neurology,B,WEST,REP-W-02,Samantha Brooks,20,true,true,,2026-05-30,,0,1,false,Harbor Medical Center
29
+ HCP-W-008,Diana Petrova,Immunology,B,WEST,REP-W-02,Samantha Brooks,20,true,true,2026-06-30,2026-07-25,,1,2,false,West Coast Immunology Center
30
+ HCP-W-009,George Fernandez,Neurology,B,WEST,REP-W-01,Carlos Mendez,20,false,true,2026-04-20,,,1,0,false,Valley Neuro Associates
31
+ HCP-W-010,Helen Tran,Immunology,B,WEST,REP-W-02,Samantha Brooks,20,true,true,2026-03-25,2026-08-02,,1,3,true,University Hospital West
32
+ HCP-W-011,Ivan Smirnov,Neurology,C,WEST,REP-W-01,Carlos Mendez,20,true,true,2026-07-28,,,1,0,false,Pacific Neuroscience Institute
33
+ HCP-W-012,Karen Lee,Immunology,C,WEST,REP-W-01,Carlos Mendez,20,true,true,,,,0,0,false,Coastal Rheumatology Group
34
+ HCP-W-013,Nathan Cole,Neurology,C,WEST,REP-W-02,Samantha Brooks,20,true,true,2026-06-05,2026-07-10,,1,1,false,Harbor Medical Center
35
+ HCP-W-014,Priscilla Dubois,Immunology,C,WEST,REP-W-02,Samantha Brooks,20,true,false,2026-08-12,,,1,0,false,West Coast Immunology Center
36
+ HCP-W-015,Oscar Herrera,Neurology,C,WEST,REP-W-01,Carlos Mendez,20,false,false,,,,0,0,false,Valley Neuro Associates
37
+ HCP-W-016,Yuki Tanaka,Immunology,C,WEST,REP-W-02,Samantha Brooks,20,true,true,,2026-06-15,,0,1,false,University Hospital West
38
+ HCP-W-017,Frank Bauer,Neurology,D,WEST,REP-W-01,Carlos Mendez,20,true,true,2026-05-05,,,1,0,false,Pacific Neuroscience Institute
39
+ HCP-W-018,Gloria Vega,Immunology,D,WEST,REP-W-01,Carlos Mendez,20,true,true,,2026-07-01,,0,1,false,Coastal Rheumatology Group
40
+ HCP-W-019,Henry Olsen,Neurology,D,WEST,REP-W-02,Samantha Brooks,20,true,false,,,,0,0,false,Harbor Medical Center
41
+ HCP-W-020,Irene Costa,Immunology,D,WEST,REP-W-02,Samantha Brooks,20,false,true,2026-04-15,,,1,0,false,West Coast Immunology Center
42
+ HCP-C-001,Richard Muller,Cardiology,A,CENTRAL,REP-C-01,Angela Foster,20,true,true,2026-07-08,2026-08-03,,2,2,true,Heartland Cardiovascular
43
+ HCP-C-002,Sophia Andersen,Endocrinology,A,CENTRAL,REP-C-01,Angela Foster,20,true,true,2026-06-20,2026-07-28,,1,2,true,Central Diabetes & Endocrine Center
44
+ HCP-C-003,Charles Obi,Cardiology,A,CENTRAL,REP-C-02,David Kim,20,true,true,,2026-07-05,,0,1,false,Midwest Heart Clinic
45
+ HCP-C-004,Nina Volkov,Endocrinology,A,CENTRAL,REP-C-02,David Kim,20,true,true,2026-08-05,2026-08-14,,3,3,true,University Medical Center Central
46
+ HCP-C-005,Peter Hoffman,Cardiology,B,CENTRAL,REP-C-01,Angela Foster,20,true,true,2026-05-25,2026-06-30,,1,1,false,Heartland Cardiovascular
47
+ HCP-C-006,Aisha Nkomo,Endocrinology,B,CENTRAL,REP-C-01,Angela Foster,20,true,false,2026-07-12,,,2,0,false,Central Diabetes & Endocrine Center
48
+ HCP-C-007,Victor Ionescu,Cardiology,B,CENTRAL,REP-C-02,David Kim,20,true,true,,2026-05-15,,0,1,false,Midwest Heart Clinic
49
+ HCP-C-008,Stephanie Cruz,Endocrinology,B,CENTRAL,REP-C-02,David Kim,20,true,true,2026-06-28,2026-07-20,,1,2,false,Prairie Endocrine Associates
50
+ HCP-C-009,Albert Schneider,Cardiology,B,CENTRAL,REP-C-01,Angela Foster,20,false,true,2026-04-05,,,1,0,false,Regional Heart Center
51
+ HCP-C-010,Beatrice Adeyemi,Endocrinology,B,CENTRAL,REP-C-02,David Kim,20,true,true,2026-03-10,2026-08-01,,1,3,true,University Medical Center Central
52
+ HCP-C-011,Carl Lindqvist,Cardiology,C,CENTRAL,REP-C-01,Angela Foster,20,true,true,2026-07-22,,,1,0,false,Heartland Cardiovascular
53
+ HCP-C-012,Diane Washington,Endocrinology,C,CENTRAL,REP-C-01,Angela Foster,20,true,true,,,,0,0,false,Central Diabetes & Endocrine Center
54
+ HCP-C-013,Eduardo Silva,Cardiology,C,CENTRAL,REP-C-02,David Kim,20,true,true,2026-06-10,2026-07-15,,1,1,false,Midwest Heart Clinic
55
+ HCP-C-014,Fiona McCarthy,Endocrinology,C,CENTRAL,REP-C-02,David Kim,20,true,false,2026-08-08,,,1,0,false,Prairie Endocrine Associates
56
+ HCP-C-015,Greg Antonov,Cardiology,C,CENTRAL,REP-C-01,Angela Foster,20,false,false,,,,0,0,false,Regional Heart Center
57
+ HCP-C-016,Hannah Eriksson,Endocrinology,C,CENTRAL,REP-C-02,David Kim,20,true,true,,2026-06-25,,0,1,false,University Medical Center Central
58
+ HCP-C-017,Ian Fletcher,Cardiology,D,CENTRAL,REP-C-01,Angela Foster,20,true,true,2026-05-15,,,1,0,false,Heartland Cardiovascular
59
+ HCP-C-018,Julia Romano,Endocrinology,D,CENTRAL,REP-C-01,Angela Foster,20,true,true,,2026-07-08,,0,1,false,Central Diabetes & Endocrine Center
60
+ HCP-C-019,Kyle Abrams,Cardiology,D,CENTRAL,REP-C-02,David Kim,20,true,true,,,,0,0,false,Midwest Heart Clinic
61
+ HCP-C-020,Monica Dupont,Endocrinology,D,CENTRAL,REP-C-02,David Kim,20,false,true,2026-04-28,,,1,0,false,Regional Heart Center
62
+ HCP-S-001,Antonio Morales,Oncology,A,SOUTH,REP-S-01,Rebecca Taylor,20,true,true,2026-07-02,2026-07-28,,2,2,true,Southern Cancer Research Center
63
+ HCP-S-002,Natalie Bergstrom,Neurology,A,SOUTH,REP-S-01,Rebecca Taylor,20,true,true,2026-06-18,2026-08-06,,1,3,true,Gulf Coast Neuro Institute
64
+ HCP-S-003,Philip Owusu,Oncology,A,SOUTH,REP-S-02,Marcus Johnson,20,true,true,,2026-07-10,,0,1,false,Sunbelt Medical Center
65
+ HCP-S-004,Camille Bonnet,Neurology,A,SOUTH,REP-S-02,Marcus Johnson,20,true,true,2026-08-03,2026-08-13,,3,2,true,University Neuro Center South
66
+ HCP-S-005,Samuel Okonkwo,Oncology,B,SOUTH,REP-S-01,Rebecca Taylor,20,true,true,2026-05-30,2026-06-28,,1,1,false,Southern Cancer Research Center
67
+ HCP-S-006,Teresa Ramirez,Neurology,B,SOUTH,REP-S-01,Rebecca Taylor,20,true,false,2026-07-20,,,2,0,false,Gulf Coast Neuro Institute
68
+ HCP-S-007,Ulrich Weber,Oncology,B,SOUTH,REP-S-02,Marcus Johnson,20,true,true,,2026-05-22,,0,1,false,Sunbelt Medical Center
69
+ HCP-S-008,Vanessa Boyd,Neurology,B,SOUTH,REP-S-02,Marcus Johnson,20,true,true,2026-06-25,2026-07-22,,1,2,false,Palmetto Neurology Practice
70
+ HCP-S-009,William Fraser,Oncology,B,SOUTH,REP-S-01,Rebecca Taylor,20,false,true,2026-04-12,,,1,0,false,Community Cancer Clinic
71
+ HCP-S-010,Xena Papadopoulos,Neurology,B,SOUTH,REP-S-02,Marcus Johnson,20,true,true,2026-03-20,2026-08-04,,1,3,true,University Neuro Center South
72
+ HCP-S-011,Yosef Abadi,Oncology,C,SOUTH,REP-S-01,Rebecca Taylor,20,true,true,2026-07-30,,,1,0,false,Southern Cancer Research Center
73
+ HCP-S-012,Zoe Campbell,Neurology,C,SOUTH,REP-S-01,Rebecca Taylor,20,true,true,,,,0,0,false,Gulf Coast Neuro Institute
74
+ HCP-S-013,Adam Kowalczyk,Oncology,C,SOUTH,REP-S-02,Marcus Johnson,20,true,true,2026-06-08,2026-07-18,,1,1,false,Sunbelt Medical Center
75
+ HCP-S-014,Bella Nguyen,Neurology,C,SOUTH,REP-S-02,Marcus Johnson,20,true,false,2026-08-10,,,1,0,false,Palmetto Neurology Practice
76
+ HCP-S-015,Cedric Dumont,Oncology,C,SOUTH,REP-S-01,Rebecca Taylor,20,false,false,,,,0,0,false,Community Cancer Clinic
77
+ HCP-S-016,Dina Khalil,Neurology,C,SOUTH,REP-S-02,Marcus Johnson,20,true,true,,2026-06-18,,0,1,false,University Neuro Center South
78
+ HCP-S-017,Ethan Ross,Oncology,D,SOUTH,REP-S-01,Rebecca Taylor,20,true,true,2026-05-08,,,1,0,false,Southern Cancer Research Center
79
+ HCP-S-018,Freya Lindgren,Neurology,D,SOUTH,REP-S-01,Rebecca Taylor,20,true,true,,2026-07-05,,0,1,false,Gulf Coast Neuro Institute
80
+ HCP-S-019,Gavin Murphy,Oncology,D,SOUTH,REP-S-02,Marcus Johnson,20,true,true,,,,0,0,false,Sunbelt Medical Center
81
+ HCP-S-020,Hana Takahashi,Neurology,D,SOUTH,REP-S-02,Marcus Johnson,20,false,true,2026-04-22,,,1,0,false,Palmetto Neurology Practice
@@ -0,0 +1,135 @@
1
+ """Pydantic models for next-best-engagement universe, constraints, and plans."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date
6
+ from typing import Literal
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field
9
+
10
+ # ---------------------------------------------------------------------------
11
+ # Input models
12
+ # ---------------------------------------------------------------------------
13
+
14
+
15
+ class ScoringWeights(BaseModel):
16
+ """Weights for each scoring factor."""
17
+
18
+ recency_gap: float = Field(default=0.30, description="Weight for recency gap factor")
19
+ tier_value: float = Field(default=0.30, description="Weight for tier value factor")
20
+ engagement_velocity: float = Field(default=0.15, description="Weight for engagement velocity factor")
21
+ channel_freshness: float = Field(default=0.15, description="Weight for channel freshness factor")
22
+ coverage_debt: float = Field(default=0.10, description="Weight for coverage debt factor")
23
+
24
+
25
+ class ConstraintConfig(BaseModel):
26
+ """Constraint parameters for plan generation. All fields optional with sensible defaults."""
27
+
28
+ period_days: int = Field(default=30, description="Planning period in days")
29
+ max_visits_per_hcp: int = Field(default=2, description="Max in-person visits per HCP per period")
30
+ max_emails_per_hcp: int = Field(default=3, description="Max emails per HCP per period")
31
+ min_gap_days: int = Field(default=7, description="Minimum days between touches to the same HCP")
32
+ tier_a_coverage_pct: float = Field(default=0.95, description="Target coverage for Tier A")
33
+ tier_b_coverage_pct: float = Field(default=0.80, description="Target coverage for Tier B")
34
+ tier_c_coverage_pct: float = Field(default=0.50, description="Target coverage for Tier C")
35
+ tier_d_coverage_pct: float = Field(default=0.20, description="Target coverage for Tier D")
36
+ no_action_threshold: float = Field(default=0.15, description="Score below which no_action is recommended")
37
+ weights: ScoringWeights = Field(default_factory=ScoringWeights)
38
+
39
+
40
+ class UniverseRow(BaseModel):
41
+ """One row in the HCP engagement universe. Extra columns are preserved as pass-through."""
42
+
43
+ model_config = ConfigDict(extra="allow")
44
+
45
+ hcp_id: str = Field(description="Unique HCP identifier")
46
+ hcp_name: str = Field(description="HCP full name")
47
+ territory_id: str = Field(description="Territory assignment")
48
+ rep_id: str = Field(description="Assigned rep identifier")
49
+
50
+ tier: Literal["A", "B", "C", "D"] = Field(default="B", description="HCP tier from segmentation")
51
+ specialty: str = Field(default="General", description="Medical specialty")
52
+ rep_name: str = Field(default="", description="Rep full name")
53
+ rep_max_visits_per_week: int = Field(default=20, description="Rep weekly visit capacity")
54
+ consent_email: bool = Field(default=True, description="HCP consented to email")
55
+ consent_phone: bool = Field(default=True, description="HCP consented to phone/meeting")
56
+ last_visit_date: date | None = Field(default=None, description="Date of last in-person visit")
57
+ last_email_date: date | None = Field(default=None, description="Date of last email")
58
+ last_meeting_date: date | None = Field(default=None, description="Date of last remote meeting")
59
+ visits_last_90d: int = Field(default=0, description="Visit count in last 90 days")
60
+ emails_last_90d: int = Field(default=0, description="Email count in last 90 days")
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Output models
65
+ # ---------------------------------------------------------------------------
66
+
67
+
68
+ class PlannedEngagement(BaseModel):
69
+ """A single recommended engagement action."""
70
+
71
+ model_config = ConfigDict(extra="allow")
72
+
73
+ hcp_id: str
74
+ hcp_name: str
75
+ specialty: str
76
+ tier: str
77
+ territory_id: str
78
+ rep_id: str
79
+ rep_name: str
80
+ action_type: Literal[
81
+ "in_person_visit",
82
+ "remote_meeting",
83
+ "approved_email",
84
+ "no_action",
85
+ ]
86
+ priority: int = Field(ge=1, le=5, description="1 = highest priority")
87
+ score: float = Field(ge=0.0, le=1.0)
88
+ suggested_window_start: date
89
+ suggested_window_end: date
90
+ rationale: str
91
+
92
+
93
+ class UnassignedHCP(BaseModel):
94
+ """An eligible HCP that could not be assigned."""
95
+
96
+ hcp_id: str
97
+ hcp_name: str
98
+ tier: str
99
+ reason: str
100
+
101
+
102
+ class TierCoverage(BaseModel):
103
+ target_pct: float
104
+ actual_pct: float
105
+ total: int
106
+ planned: int
107
+ gap: int
108
+
109
+
110
+ class RepUtilization(BaseModel):
111
+ rep_id: str
112
+ rep_name: str
113
+ capacity: int
114
+ assigned: int
115
+ utilization_pct: float
116
+
117
+
118
+ class PlanMetrics(BaseModel):
119
+ total_eligible: int
120
+ total_planned: int
121
+ coverage_pct: float
122
+ coverage_by_tier: dict[str, TierCoverage]
123
+ rep_utilization: list[RepUtilization]
124
+ channel_mix: dict[str, int]
125
+ no_action_count: int
126
+ no_action_reasons: dict[str, int]
127
+
128
+
129
+ class EngagementPlan(BaseModel):
130
+ period_start: date
131
+ period_end: date
132
+ generated_at: str
133
+ engagements: list[PlannedEngagement]
134
+ unassigned: list[UnassignedHCP]
135
+ metrics: PlanMetrics
@@ -0,0 +1,47 @@
1
+ """load_universe — load HCP engagement data from CSV or built-in fixtures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class LoadUniverseArgs(BaseModel):
11
+ source: str = Field(
12
+ default="fixture",
13
+ description=("Data source: 'fixture' to load built-in sample data, or an absolute path to a CSV file."),
14
+ )
15
+
16
+
17
+ TOOL: dict[str, Any] = {
18
+ "name": "load_universe",
19
+ "description": (
20
+ "Load the HCP engagement universe from a CSV file or built-in fixture "
21
+ "data. The CSV must have columns: hcp_id, hcp_name, territory_id, "
22
+ "rep_id. Optional columns (tier, specialty, consent_email, "
23
+ "consent_phone, last_visit_date, etc.) enrich scoring. Any extra "
24
+ "columns are preserved and passed through to the output plan. "
25
+ "Call this before recommend_engagements."
26
+ ),
27
+ "args": LoadUniverseArgs,
28
+ }
29
+
30
+
31
+ def handle(arguments: dict[str, Any]) -> list[dict[str, Any]]:
32
+ import json
33
+ import os
34
+
35
+ from .._universe import get_summary, load_csv, load_fixture
36
+
37
+ source = arguments.get("source", "fixture")
38
+
39
+ if source == "fixture":
40
+ load_fixture()
41
+ else:
42
+ if not os.path.isfile(source):
43
+ return [{"type": "text", "text": json.dumps({"error": f"File not found: {source}"})}]
44
+ load_csv(source)
45
+
46
+ summary = get_summary()
47
+ return [{"type": "text", "text": json.dumps(summary, indent=2)}]
@@ -0,0 +1,80 @@
1
+ """recommend_engagements — score, filter, and optimise the engagement plan."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class RecommendEngagementsArgs(BaseModel):
11
+ period_days: int = Field(default=30, description="Planning period length in days")
12
+ max_visits_per_hcp: int = Field(default=2, description="Max in-person visits per HCP in the period")
13
+ max_emails_per_hcp: int = Field(default=3, description="Max approved emails per HCP in the period")
14
+ min_gap_days: int = Field(default=7, description="Minimum days between touches to the same HCP")
15
+ no_action_threshold: float = Field(
16
+ default=0.15,
17
+ description="Score below which the HCP receives no action",
18
+ )
19
+ tier_a_coverage_pct: float = Field(default=0.95, description="Tier A coverage target")
20
+ tier_b_coverage_pct: float = Field(default=0.80, description="Tier B coverage target")
21
+ tier_c_coverage_pct: float = Field(default=0.50, description="Tier C coverage target")
22
+ tier_d_coverage_pct: float = Field(default=0.20, description="Tier D coverage target")
23
+ weight_recency_gap: float | None = Field(
24
+ default=None, description="Override recency gap scoring weight (default 0.30)"
25
+ )
26
+ weight_tier_value: float | None = Field(
27
+ default=None, description="Override tier value scoring weight (default 0.30)"
28
+ )
29
+ weight_engagement_velocity: float | None = Field(
30
+ default=None, description="Override engagement velocity scoring weight (default 0.15)"
31
+ )
32
+ weight_channel_freshness: float | None = Field(
33
+ default=None, description="Override channel freshness scoring weight (default 0.15)"
34
+ )
35
+ weight_coverage_debt: float | None = Field(
36
+ default=None, description="Override coverage debt scoring weight (default 0.10)"
37
+ )
38
+
39
+
40
+ TOOL: dict[str, Any] = {
41
+ "name": "recommend_engagements",
42
+ "description": (
43
+ "Generate the next-best-engagement plan for the loaded HCP universe. "
44
+ "Scores each HCP using a weighted linear model (recency gap, tier "
45
+ "value, engagement velocity, channel freshness, coverage debt), "
46
+ "selects actions via a rule cascade, and allocates to reps using a "
47
+ "two-phase greedy optimiser that satisfies coverage targets first, "
48
+ "then fills remaining capacity by score. Returns a structured "
49
+ "EngagementPlan with planned engagements, unassigned HCPs, and "
50
+ "metrics. Call load_universe first."
51
+ ),
52
+ "args": RecommendEngagementsArgs,
53
+ }
54
+
55
+
56
+ def handle(arguments: dict[str, Any]) -> list[dict[str, Any]]:
57
+ import json
58
+
59
+ from .._optimizer import generate_plan
60
+ from .._universe import get_rep_info, get_universe, store_plan
61
+ from ..models import ConstraintConfig, ScoringWeights
62
+
63
+ universe = get_universe()
64
+ if not universe:
65
+ return [{"type": "text", "text": json.dumps({"error": "No universe loaded. Call load_universe first."})}]
66
+
67
+ weight_overrides = {
68
+ k.removeprefix("weight_"): v for k, v in arguments.items() if k.startswith("weight_") and v is not None
69
+ }
70
+ constraint_fields = {k: v for k, v in arguments.items() if v is not None and not k.startswith("weight_")}
71
+ if weight_overrides:
72
+ constraint_fields["weights"] = ScoringWeights(**{**ScoringWeights().model_dump(), **weight_overrides})
73
+
74
+ rep_info = get_rep_info()
75
+ config = ConstraintConfig(**constraint_fields)
76
+ plan = generate_plan(universe, rep_info, config)
77
+ store_plan(plan)
78
+
79
+ result = json.loads(plan.model_dump_json())
80
+ return [{"type": "text", "text": json.dumps(result, indent=2)}]
@@ -0,0 +1,90 @@
1
+ """render_plan — export the engagement plan as CSV files or JSON."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class RenderPlanArgs(BaseModel):
11
+ format: str = Field(
12
+ default="csv",
13
+ description="Output format: 'csv' writes two CSV files, 'json' returns the full plan",
14
+ )
15
+ output_dir: str = Field(
16
+ default="",
17
+ description=(
18
+ "Directory for CSV output. Defaults to ~/.open-pharma-plugins/next-best-engagement/. "
19
+ "Ignored for JSON format."
20
+ ),
21
+ )
22
+ plan_json: str = Field(
23
+ default="",
24
+ description=(
25
+ "The engagement plan as a JSON string. If omitted, uses the last plan generated by recommend_engagements."
26
+ ),
27
+ )
28
+
29
+
30
+ TOOL: dict[str, Any] = {
31
+ "name": "render_plan",
32
+ "description": (
33
+ "Export the engagement plan to CSV files or return it as structured "
34
+ "JSON. CSV mode writes engagements.csv (one row per planned action "
35
+ "including any extra columns from the input universe) and "
36
+ "plan_summary.csv (coverage, utilisation, channel mix metrics). "
37
+ "Call recommend_engagements first to obtain the plan JSON."
38
+ ),
39
+ "args": RenderPlanArgs,
40
+ }
41
+
42
+
43
+ def handle(arguments: dict[str, Any]) -> list[dict[str, Any]]:
44
+ import json
45
+ from pathlib import Path
46
+
47
+ from .._renderer import render_csv, render_json
48
+ from .._universe import get_last_plan
49
+ from ..models import EngagementPlan
50
+
51
+ plan_json = arguments.get("plan_json", "").strip()
52
+
53
+ if plan_json:
54
+ try:
55
+ plan = EngagementPlan.model_validate_json(plan_json)
56
+ except Exception as e:
57
+ return [{"type": "text", "text": json.dumps({"error": f"Invalid plan JSON: {e}"})}]
58
+ else:
59
+ plan = get_last_plan()
60
+ if plan is None:
61
+ return [
62
+ {
63
+ "type": "text",
64
+ "text": json.dumps(
65
+ {"error": "No plan available. Call recommend_engagements first or pass plan_json."}
66
+ ),
67
+ }
68
+ ]
69
+
70
+ fmt = arguments.get("format", "csv")
71
+
72
+ if fmt == "json":
73
+ return [{"type": "text", "text": render_json(plan)}]
74
+
75
+ output_dir = arguments.get("output_dir", "").strip()
76
+ if not output_dir:
77
+ from shared.env import get_env
78
+
79
+ output_dir = get_env(
80
+ "OPEN_PHARMA_NBE_OUTPUT_DIR",
81
+ str(Path.home() / ".open-pharma-plugins" / "next-best-engagement"),
82
+ )
83
+
84
+ paths = render_csv(plan, output_dir)
85
+ result = {
86
+ "status": "ok",
87
+ "format": "csv",
88
+ "files": paths,
89
+ }
90
+ return [{"type": "text", "text": json.dumps(result, indent=2)}]