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.
- mcp_framework.py +495 -0
- open_pharma_plugins-2.2.0.dist-info/METADATA +135 -0
- open_pharma_plugins-2.2.0.dist-info/RECORD +136 -0
- open_pharma_plugins-2.2.0.dist-info/WHEEL +5 -0
- open_pharma_plugins-2.2.0.dist-info/entry_points.txt +8 -0
- open_pharma_plugins-2.2.0.dist-info/licenses/LICENSE +202 -0
- open_pharma_plugins-2.2.0.dist-info/top_level.txt +8 -0
- open_pharma_plugins_campaign_studio/__init__.py +14 -0
- open_pharma_plugins_campaign_studio/__main__.py +11 -0
- open_pharma_plugins_campaign_studio/_campaign_store.py +162 -0
- open_pharma_plugins_campaign_studio/_claim_engine.py +262 -0
- open_pharma_plugins_campaign_studio/_renderer.py +119 -0
- open_pharma_plugins_campaign_studio/fixtures/brand_kit/legal.json +21 -0
- open_pharma_plugins_campaign_studio/fixtures/brand_kit/logo.svg +4 -0
- open_pharma_plugins_campaign_studio/fixtures/brand_kit/palette.json +11 -0
- open_pharma_plugins_campaign_studio/fixtures/brand_kit/product.png +1 -0
- open_pharma_plugins_campaign_studio/fixtures/brand_kit/typography.json +14 -0
- open_pharma_plugins_campaign_studio/fixtures/sample_approved_claims.json +119 -0
- open_pharma_plugins_campaign_studio/models/__init__.py +34 -0
- open_pharma_plugins_campaign_studio/models/_common.py +12 -0
- open_pharma_plugins_campaign_studio/models/brief.py +72 -0
- open_pharma_plugins_campaign_studio/models/claims.py +15 -0
- open_pharma_plugins_campaign_studio/models/copy.py +47 -0
- open_pharma_plugins_campaign_studio/models/journey.py +21 -0
- open_pharma_plugins_campaign_studio/models/message.py +25 -0
- open_pharma_plugins_campaign_studio/models/mlr.py +29 -0
- open_pharma_plugins_campaign_studio/models/validation.py +32 -0
- open_pharma_plugins_campaign_studio/policy/rules.json +79 -0
- open_pharma_plugins_campaign_studio/templates/banner.svg.j2 +26 -0
- open_pharma_plugins_campaign_studio/templates/email.html.j2 +54 -0
- open_pharma_plugins_campaign_studio/tools/__init__.py +0 -0
- open_pharma_plugins_campaign_studio/tools/create_campaign_brief.py +212 -0
- open_pharma_plugins_campaign_studio/tools/generate_audience_journey.py +129 -0
- open_pharma_plugins_campaign_studio/tools/generate_channel_copy.py +199 -0
- open_pharma_plugins_campaign_studio/tools/generate_message_architecture.py +121 -0
- open_pharma_plugins_campaign_studio/tools/package_mlr_submission.py +230 -0
- open_pharma_plugins_campaign_studio/tools/render_banner.py +99 -0
- open_pharma_plugins_campaign_studio/tools/render_email.py +101 -0
- open_pharma_plugins_campaign_studio/tools/render_poster.py +222 -0
- open_pharma_plugins_campaign_studio/tools/retrieve_approved_claims.py +71 -0
- open_pharma_plugins_campaign_studio/tools/retrieve_brand_components.py +76 -0
- open_pharma_plugins_campaign_studio/tools/validate_claims_and_fair_balance.py +285 -0
- open_pharma_plugins_competitive_intelligence/__init__.py +13 -0
- open_pharma_plugins_competitive_intelligence/__main__.py +11 -0
- open_pharma_plugins_competitive_intelligence/_artifacts.py +87 -0
- open_pharma_plugins_competitive_intelligence/_cache.py +144 -0
- open_pharma_plugins_competitive_intelligence/_clinical_trials.py +569 -0
- open_pharma_plugins_competitive_intelligence/_dailymed.py +260 -0
- open_pharma_plugins_competitive_intelligence/_fda.py +255 -0
- open_pharma_plugins_competitive_intelligence/_pubmed.py +342 -0
- open_pharma_plugins_competitive_intelligence/_regulatory.py +140 -0
- open_pharma_plugins_competitive_intelligence/_runs.py +278 -0
- open_pharma_plugins_competitive_intelligence/_transport.py +83 -0
- open_pharma_plugins_competitive_intelligence/_watchlist.py +113 -0
- open_pharma_plugins_competitive_intelligence/_web_search.py +331 -0
- open_pharma_plugins_competitive_intelligence/models.py +525 -0
- open_pharma_plugins_competitive_intelligence/tools/__init__.py +0 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_extract_events.py +247 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_landscape.py +195 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_refresh.py +101 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_report.py +402 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_scan_news.py +48 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_scan_publications.py +46 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_scan_regulatory.py +64 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_scan_trials.py +85 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_status.py +106 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_timeline.py +453 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_track.py +121 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_trial_detail.py +55 -0
- open_pharma_plugins_field_training/__init__.py +13 -0
- open_pharma_plugins_field_training/__main__.py +11 -0
- open_pharma_plugins_field_training/_content_store.py +131 -0
- open_pharma_plugins_field_training/_grounding.py +75 -0
- open_pharma_plugins_field_training/_html_renderers.py +546 -0
- open_pharma_plugins_field_training/fixtures/sample_product_message.pdf +156 -0
- open_pharma_plugins_field_training/fixtures/sample_training_deck.pptx +0 -0
- open_pharma_plugins_field_training/models.py +265 -0
- open_pharma_plugins_field_training/tools/__init__.py +0 -0
- open_pharma_plugins_field_training/tools/get_document_page.py +67 -0
- open_pharma_plugins_field_training/tools/ingest_document.py +147 -0
- open_pharma_plugins_field_training/tools/list_documents.py +51 -0
- open_pharma_plugins_field_training/tools/render_output.py +118 -0
- open_pharma_plugins_field_training/tools/search_content.py +57 -0
- open_pharma_plugins_hcp_intelligence/__init__.py +14 -0
- open_pharma_plugins_hcp_intelligence/__main__.py +11 -0
- open_pharma_plugins_hcp_intelligence/_crm_store.py +70 -0
- open_pharma_plugins_hcp_intelligence/batch.py +891 -0
- open_pharma_plugins_hcp_intelligence/batch_cli.py +221 -0
- open_pharma_plugins_hcp_intelligence/batch_csv.py +206 -0
- open_pharma_plugins_hcp_intelligence/fixtures/sample_accounts.csv +27 -0
- open_pharma_plugins_hcp_intelligence/models.py +356 -0
- open_pharma_plugins_hcp_intelligence/tools/__init__.py +0 -0
- open_pharma_plugins_hcp_intelligence/tools/get_account.py +48 -0
- open_pharma_plugins_hcp_intelligence/tools/list_accounts.py +66 -0
- open_pharma_plugins_hcp_intelligence/tools/search_clinical_trials.py +175 -0
- open_pharma_plugins_hcp_intelligence/tools/search_congresses.py +134 -0
- open_pharma_plugins_hcp_intelligence/tools/search_grants.py +181 -0
- open_pharma_plugins_hcp_intelligence/tools/search_guidelines.py +238 -0
- open_pharma_plugins_hcp_intelligence/tools/search_hco_web.py +73 -0
- open_pharma_plugins_hcp_intelligence/tools/search_hcp_web.py +199 -0
- open_pharma_plugins_hcp_intelligence/tools/search_orcid.py +214 -0
- open_pharma_plugins_hcp_intelligence/tools/search_publications.py +207 -0
- open_pharma_plugins_hcp_intelligence/tools/update_account.py +84 -0
- open_pharma_plugins_next_best_engagement/__init__.py +14 -0
- open_pharma_plugins_next_best_engagement/__main__.py +11 -0
- open_pharma_plugins_next_best_engagement/_optimizer.py +400 -0
- open_pharma_plugins_next_best_engagement/_renderer.py +149 -0
- open_pharma_plugins_next_best_engagement/_scoring.py +82 -0
- open_pharma_plugins_next_best_engagement/_universe.py +145 -0
- open_pharma_plugins_next_best_engagement/fixtures/sample_universe.csv +81 -0
- open_pharma_plugins_next_best_engagement/models.py +135 -0
- open_pharma_plugins_next_best_engagement/tools/__init__.py +0 -0
- open_pharma_plugins_next_best_engagement/tools/load_universe.py +47 -0
- open_pharma_plugins_next_best_engagement/tools/recommend_engagements.py +80 -0
- open_pharma_plugins_next_best_engagement/tools/render_plan.py +90 -0
- open_pharma_plugins_territory_alignment/__init__.py +14 -0
- open_pharma_plugins_territory_alignment/__main__.py +11 -0
- open_pharma_plugins_territory_alignment/data.py +300 -0
- open_pharma_plugins_territory_alignment/fixtures/constraints.csv +11 -0
- open_pharma_plugins_territory_alignment/fixtures/current_alignment.csv +81 -0
- open_pharma_plugins_territory_alignment/fixtures/hcps.csv +81 -0
- open_pharma_plugins_territory_alignment/fixtures/reps.csv +9 -0
- open_pharma_plugins_territory_alignment/geo.py +175 -0
- open_pharma_plugins_territory_alignment/models.py +201 -0
- open_pharma_plugins_territory_alignment/scoring.py +125 -0
- open_pharma_plugins_territory_alignment/solver.py +504 -0
- open_pharma_plugins_territory_alignment/tools/__init__.py +0 -0
- open_pharma_plugins_territory_alignment/tools/ta_align.py +138 -0
- open_pharma_plugins_territory_alignment/tools/ta_cluster.py +247 -0
- open_pharma_plugins_territory_alignment/tools/ta_compare.py +173 -0
- open_pharma_plugins_territory_alignment/tools/ta_evaluate.py +119 -0
- open_pharma_plugins_territory_alignment/tools/ta_status.py +34 -0
- open_pharma_plugins_territory_alignment/tools/ta_visualize.py +504 -0
- shared/__init__.py +11 -0
- shared/env.py +217 -0
- 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
|
|
File without changes
|
|
@@ -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)}]
|