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,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,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
|