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,262 @@
|
|
|
1
|
+
"""Claim validation engine — fuzzy matching, fair balance, and policy checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from importlib.resources import files
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def load_policy_rules(jurisdiction: str) -> dict:
|
|
12
|
+
"""Load jurisdiction-specific policy rules from rules.json."""
|
|
13
|
+
rules_path = Path(str(files("open_pharma_plugins_campaign_studio") / "policy" / "rules.json"))
|
|
14
|
+
all_rules = json.loads(rules_path.read_text())
|
|
15
|
+
return all_rules.get(jurisdiction, all_rules.get("FDA", {}))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def fuzzy_match(statement: str, approved_claims: list[dict]) -> dict:
|
|
19
|
+
"""Match a statement against approved claims using token overlap.
|
|
20
|
+
|
|
21
|
+
Returns the best match with similarity score, or a not_found result.
|
|
22
|
+
"""
|
|
23
|
+
statement_tokens = _tokenize(statement)
|
|
24
|
+
if not statement_tokens:
|
|
25
|
+
return {
|
|
26
|
+
"claim_id": None,
|
|
27
|
+
"status": "not_found",
|
|
28
|
+
"matched_claim_text": None,
|
|
29
|
+
"similarity_score": 0.0,
|
|
30
|
+
"deviation": "Empty statement",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
best_score = 0.0
|
|
34
|
+
best_claim: dict | None = None
|
|
35
|
+
|
|
36
|
+
for claim in approved_claims:
|
|
37
|
+
claim_tokens = _tokenize(claim.get("text", ""))
|
|
38
|
+
if not claim_tokens:
|
|
39
|
+
continue
|
|
40
|
+
|
|
41
|
+
intersection = statement_tokens & claim_tokens
|
|
42
|
+
union = statement_tokens | claim_tokens
|
|
43
|
+
score = len(intersection) / len(union) if union else 0.0
|
|
44
|
+
|
|
45
|
+
if score > best_score:
|
|
46
|
+
best_score = score
|
|
47
|
+
best_claim = claim
|
|
48
|
+
|
|
49
|
+
if best_score >= 0.6:
|
|
50
|
+
deviation = None
|
|
51
|
+
if best_score < 1.0 and best_claim:
|
|
52
|
+
claim_tokens = _tokenize(best_claim["text"])
|
|
53
|
+
missing = claim_tokens - statement_tokens
|
|
54
|
+
extra = statement_tokens - claim_tokens
|
|
55
|
+
parts = []
|
|
56
|
+
if missing:
|
|
57
|
+
parts.append(f"missing terms: {', '.join(sorted(missing)[:5])}")
|
|
58
|
+
if extra:
|
|
59
|
+
parts.append(f"extra terms: {', '.join(sorted(extra)[:5])}")
|
|
60
|
+
deviation = "; ".join(parts) if parts else None
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
"claim_id": best_claim["claim_id"] if best_claim else None,
|
|
64
|
+
"status": "approved" if best_score >= 0.8 else "needs_review",
|
|
65
|
+
"matched_claim_text": best_claim["text"] if best_claim else None,
|
|
66
|
+
"similarity_score": round(best_score, 3),
|
|
67
|
+
"deviation": deviation,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
"claim_id": best_claim["claim_id"] if best_claim else None,
|
|
72
|
+
"status": "not_found",
|
|
73
|
+
"matched_claim_text": best_claim["text"] if best_claim else None,
|
|
74
|
+
"similarity_score": round(best_score, 3),
|
|
75
|
+
"deviation": "No close match found in approved claims",
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def check_fair_balance(copy_blocks: list[dict], approved_claims: list[dict], min_ratio: float) -> dict:
|
|
80
|
+
"""Check that safety content accompanies efficacy claims.
|
|
81
|
+
|
|
82
|
+
Returns a policy check result with the efficacy-to-safety ratio.
|
|
83
|
+
"""
|
|
84
|
+
claim_id_to_category = {c["claim_id"]: c.get("category", "").lower() for c in approved_claims}
|
|
85
|
+
|
|
86
|
+
efficacy_count = 0
|
|
87
|
+
safety_count = 0
|
|
88
|
+
|
|
89
|
+
for block in copy_blocks:
|
|
90
|
+
for cid in block.get("claim_ids", []):
|
|
91
|
+
cat = claim_id_to_category.get(cid, "")
|
|
92
|
+
if cat in ("efficacy", "positioning", "moa"):
|
|
93
|
+
efficacy_count += 1
|
|
94
|
+
elif cat in ("safety", "tolerability"):
|
|
95
|
+
safety_count += 1
|
|
96
|
+
|
|
97
|
+
total = efficacy_count + safety_count
|
|
98
|
+
if total == 0:
|
|
99
|
+
return {
|
|
100
|
+
"check_name": "fair_balance",
|
|
101
|
+
"result": "pass",
|
|
102
|
+
"detail": "No efficacy or safety claims found in copy.",
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
safety_ratio = safety_count / total if total > 0 else 0.0
|
|
106
|
+
|
|
107
|
+
if safety_ratio >= min_ratio:
|
|
108
|
+
return {
|
|
109
|
+
"check_name": "fair_balance",
|
|
110
|
+
"result": "pass",
|
|
111
|
+
"detail": (
|
|
112
|
+
f"Safety ratio: {safety_ratio:.0%} "
|
|
113
|
+
f"({safety_count} safety / {total} total). "
|
|
114
|
+
f"Meets minimum {min_ratio:.0%}."
|
|
115
|
+
),
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
"check_name": "fair_balance",
|
|
120
|
+
"result": "fail",
|
|
121
|
+
"detail": (
|
|
122
|
+
f"Safety ratio: {safety_ratio:.0%} "
|
|
123
|
+
f"({safety_count} safety / {total} total). "
|
|
124
|
+
f"Below minimum {min_ratio:.0%}. "
|
|
125
|
+
"Add more safety/tolerability content."
|
|
126
|
+
),
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def check_prohibited_language(text: str, patterns: list[dict]) -> list[dict]:
|
|
131
|
+
"""Check text against prohibited language patterns."""
|
|
132
|
+
violations: list[dict] = []
|
|
133
|
+
for rule in patterns:
|
|
134
|
+
if re.search(rule["pattern"], text, re.IGNORECASE):
|
|
135
|
+
violations.append(
|
|
136
|
+
{
|
|
137
|
+
"check_name": "prohibited_language",
|
|
138
|
+
"result": "fail",
|
|
139
|
+
"detail": f'{rule["reason"]}. Found in: "{_excerpt(text, rule["pattern"])}"',
|
|
140
|
+
}
|
|
141
|
+
)
|
|
142
|
+
return violations
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def check_required_elements(all_text: str, legal_data: dict, required: list[str]) -> list[dict]:
|
|
146
|
+
"""Check that required legal elements are present in the copy."""
|
|
147
|
+
results: list[dict] = []
|
|
148
|
+
for element in required:
|
|
149
|
+
element_text = legal_data.get(element, "")
|
|
150
|
+
if not element_text:
|
|
151
|
+
results.append(
|
|
152
|
+
{
|
|
153
|
+
"check_name": f"required_{element}",
|
|
154
|
+
"result": "fail",
|
|
155
|
+
"detail": f"Required element '{element}' not found in brand kit legal content.",
|
|
156
|
+
}
|
|
157
|
+
)
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
keywords = _tokenize(element_text)
|
|
161
|
+
text_tokens = _tokenize(all_text)
|
|
162
|
+
overlap = len(keywords & text_tokens) / len(keywords) if keywords else 0
|
|
163
|
+
|
|
164
|
+
if overlap >= 0.3:
|
|
165
|
+
results.append(
|
|
166
|
+
{
|
|
167
|
+
"check_name": f"required_{element}",
|
|
168
|
+
"result": "pass",
|
|
169
|
+
"detail": f"Required element '{element}' found in copy.",
|
|
170
|
+
}
|
|
171
|
+
)
|
|
172
|
+
else:
|
|
173
|
+
results.append(
|
|
174
|
+
{
|
|
175
|
+
"check_name": f"required_{element}",
|
|
176
|
+
"result": "fail",
|
|
177
|
+
"detail": f"Required element '{element}' not found in copy. This must be included per {element} regulations.",
|
|
178
|
+
}
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
return results
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def is_claim_citation_exempt(block_name: str, text: str, brief: dict, legal_data: dict) -> bool:
|
|
185
|
+
"""Only the configured CTA and verbatim approved legal text may be uncited."""
|
|
186
|
+
normalized = _normalize(text)
|
|
187
|
+
if not normalized:
|
|
188
|
+
return True
|
|
189
|
+
if block_name == "cta" and normalized == _normalize(brief.get("call_to_action", "")):
|
|
190
|
+
return True
|
|
191
|
+
approved_legal = {
|
|
192
|
+
_normalize(value)
|
|
193
|
+
for key, value in legal_data.items()
|
|
194
|
+
if key != "jurisdictions" and isinstance(value, str) and value.strip()
|
|
195
|
+
}
|
|
196
|
+
return normalized in approved_legal
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _normalize(text: str) -> str:
|
|
200
|
+
return " ".join(str(text).split()).casefold()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _tokenize(text: str) -> set[str]:
|
|
204
|
+
"""Lowercase tokenization with stop-word removal."""
|
|
205
|
+
stop_words = {
|
|
206
|
+
"a",
|
|
207
|
+
"an",
|
|
208
|
+
"the",
|
|
209
|
+
"and",
|
|
210
|
+
"or",
|
|
211
|
+
"but",
|
|
212
|
+
"in",
|
|
213
|
+
"on",
|
|
214
|
+
"at",
|
|
215
|
+
"to",
|
|
216
|
+
"for",
|
|
217
|
+
"of",
|
|
218
|
+
"with",
|
|
219
|
+
"by",
|
|
220
|
+
"from",
|
|
221
|
+
"is",
|
|
222
|
+
"was",
|
|
223
|
+
"were",
|
|
224
|
+
"are",
|
|
225
|
+
"been",
|
|
226
|
+
"be",
|
|
227
|
+
"have",
|
|
228
|
+
"has",
|
|
229
|
+
"had",
|
|
230
|
+
"do",
|
|
231
|
+
"does",
|
|
232
|
+
"did",
|
|
233
|
+
"will",
|
|
234
|
+
"would",
|
|
235
|
+
"could",
|
|
236
|
+
"should",
|
|
237
|
+
"may",
|
|
238
|
+
"might",
|
|
239
|
+
"can",
|
|
240
|
+
"shall",
|
|
241
|
+
"that",
|
|
242
|
+
"this",
|
|
243
|
+
"these",
|
|
244
|
+
"those",
|
|
245
|
+
"it",
|
|
246
|
+
"its",
|
|
247
|
+
"as",
|
|
248
|
+
"not",
|
|
249
|
+
"no",
|
|
250
|
+
}
|
|
251
|
+
tokens = set(re.findall(r"\b[a-z0-9][a-z0-9.%<>=-]*\b", text.lower()))
|
|
252
|
+
return tokens - stop_words
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _excerpt(text: str, pattern: str) -> str:
|
|
256
|
+
"""Extract a short excerpt around the first match."""
|
|
257
|
+
m = re.search(pattern, text, re.IGNORECASE)
|
|
258
|
+
if not m:
|
|
259
|
+
return ""
|
|
260
|
+
start = max(0, m.start() - 20)
|
|
261
|
+
end = min(len(text), m.end() + 20)
|
|
262
|
+
return text[start:end].strip()
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Shared rendering utilities — template loading, brand tokens, validation gate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
from importlib.resources import files
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def validation_input_fingerprint(campaign_brief_id: str, channels: list[str]) -> str:
|
|
13
|
+
"""Bind a validation decision to the exact brief, claims, and copy inputs."""
|
|
14
|
+
from ._campaign_store import load_artifact, load_brief
|
|
15
|
+
|
|
16
|
+
ordered_channels = sorted(set(channels))
|
|
17
|
+
payload = {
|
|
18
|
+
"brief": load_brief(campaign_brief_id),
|
|
19
|
+
"approved_claims": load_artifact(campaign_brief_id, "approved-claims.json"),
|
|
20
|
+
"copies": {channel: load_artifact(campaign_brief_id, f"copy-{channel}.json") for channel in ordered_channels},
|
|
21
|
+
}
|
|
22
|
+
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str)
|
|
23
|
+
return hashlib.sha256(canonical.encode()).hexdigest()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def check_validation_gate(campaign_brief_id: str, channel: str | None = None) -> str | None:
|
|
27
|
+
"""Return an error message if validation hasn't passed, else None."""
|
|
28
|
+
from ._campaign_store import load_brief, load_validation_artifact
|
|
29
|
+
|
|
30
|
+
report = load_validation_artifact(campaign_brief_id, "policy-checks.json")
|
|
31
|
+
if not report:
|
|
32
|
+
return "No validation report found. Run validate_claims_and_fair_balance before rendering."
|
|
33
|
+
if not report.get("overall_pass"):
|
|
34
|
+
failures = [c for c in report.get("policy_checks", []) if c.get("result") == "fail"]
|
|
35
|
+
fail_names = [f.get("check_name", "unknown") for f in failures]
|
|
36
|
+
return (
|
|
37
|
+
f"Validation failed ({len(failures)} failures: "
|
|
38
|
+
f"{', '.join(fail_names)}). Fix and re-validate before rendering."
|
|
39
|
+
)
|
|
40
|
+
validated_channels = report.get("channels_validated", [])
|
|
41
|
+
if channel and channel not in validated_channels:
|
|
42
|
+
return f"Channel '{channel}' was not included in the passing validation report. Re-validate before rendering."
|
|
43
|
+
if channel is None:
|
|
44
|
+
brief_channels = (load_brief(campaign_brief_id) or {}).get("channels", [])
|
|
45
|
+
if set(validated_channels) != set(brief_channels):
|
|
46
|
+
return "The passing validation report does not cover every campaign channel. Re-validate before packaging."
|
|
47
|
+
stored_fingerprint = report.get("input_fingerprint")
|
|
48
|
+
current_fingerprint = validation_input_fingerprint(campaign_brief_id, validated_channels)
|
|
49
|
+
if not stored_fingerprint or not hmac.compare_digest(stored_fingerprint, current_fingerprint):
|
|
50
|
+
return "Campaign inputs changed after validation. Re-run validation before rendering or packaging."
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_template(name: str) -> str:
|
|
55
|
+
"""Load a Jinja2 template from the templates directory."""
|
|
56
|
+
tpl_path = Path(str(files("open_pharma_plugins_campaign_studio") / "templates" / name))
|
|
57
|
+
return tpl_path.read_text()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load_brand_kit(campaign_brief_id: str) -> dict:
|
|
61
|
+
"""Load full brand kit (palette, typography, legal, paths)."""
|
|
62
|
+
from ._campaign_store import load_brief
|
|
63
|
+
|
|
64
|
+
brief = load_brief(campaign_brief_id)
|
|
65
|
+
kit_path = None
|
|
66
|
+
if brief:
|
|
67
|
+
kit_path = brief.get("brand_kit_path")
|
|
68
|
+
|
|
69
|
+
if kit_path and Path(kit_path).is_dir():
|
|
70
|
+
kit_dir = Path(kit_path)
|
|
71
|
+
else:
|
|
72
|
+
kit_dir = Path(str(files("open_pharma_plugins_campaign_studio") / "fixtures" / "brand_kit"))
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
"palette": _load_json(kit_dir / "palette.json"),
|
|
76
|
+
"typography": _load_json(kit_dir / "typography.json"),
|
|
77
|
+
"legal": _load_json(kit_dir / "legal.json"),
|
|
78
|
+
"logo_path": str(kit_dir / "logo.svg"),
|
|
79
|
+
"product_image_path": str(kit_dir / "product.png"),
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def extract_copy_text(copy_data: dict, channel: str) -> dict:
|
|
84
|
+
"""Extract text fields from channel copy into a flat dict for templates."""
|
|
85
|
+
result: dict = {}
|
|
86
|
+
|
|
87
|
+
if channel == "email":
|
|
88
|
+
result["subject"] = _block_text(copy_data.get("subject"))
|
|
89
|
+
result["preheader"] = _block_text(copy_data.get("preheader"))
|
|
90
|
+
result["headline"] = _block_text(copy_data.get("headline"))
|
|
91
|
+
result["cta"] = _block_text(copy_data.get("cta"))
|
|
92
|
+
result["body_paragraphs"] = [_block_text(b) for b in copy_data.get("body", [])]
|
|
93
|
+
elif channel == "banner":
|
|
94
|
+
result["headline"] = _block_text(copy_data.get("headline"))
|
|
95
|
+
result["sub_headline"] = _block_text(copy_data.get("sub_headline"))
|
|
96
|
+
result["cta"] = _block_text(copy_data.get("cta"))
|
|
97
|
+
elif channel == "poster":
|
|
98
|
+
result["headline"] = _block_text(copy_data.get("headline"))
|
|
99
|
+
result["subhead"] = _block_text(copy_data.get("subhead"))
|
|
100
|
+
result["cta"] = _block_text(copy_data.get("cta"))
|
|
101
|
+
result["body_paragraphs"] = [_block_text(b) for b in copy_data.get("body", [])]
|
|
102
|
+
result["bullet_points"] = [_block_text(b) for b in (copy_data.get("bullet_points") or [])]
|
|
103
|
+
result["footnotes"] = copy_data.get("footnotes") or []
|
|
104
|
+
|
|
105
|
+
return result
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _block_text(block: dict | None) -> str:
|
|
109
|
+
if not block:
|
|
110
|
+
return ""
|
|
111
|
+
if isinstance(block, dict):
|
|
112
|
+
return block.get("text", "")
|
|
113
|
+
return str(block)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _load_json(path: Path) -> dict:
|
|
117
|
+
if path.exists():
|
|
118
|
+
return json.loads(path.read_text())
|
|
119
|
+
return {}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"isi": "IMPORTANT SAFETY INFORMATION: ONCORIX can cause immune-mediated adverse reactions including pneumonitis, colitis, hepatitis, endocrinopathies, and nephritis. Monitor patients for signs and symptoms. Withhold or permanently discontinue ONCORIX based on severity. See full Prescribing Information for complete boxed warning and additional important safety information.",
|
|
3
|
+
"pi_ref": "Please see full Prescribing Information, including Boxed WARNING, at www.oncorix-hcp.example.com/pi",
|
|
4
|
+
"copyright": "© 2026 Example Pharma Inc. All rights reserved.",
|
|
5
|
+
"reporting_statement": "You are encouraged to report adverse events. Contact Example Pharma at 1-800-XXX-XXXX or FDA at 1-800-FDA-1088 or www.fda.gov/medwatch.",
|
|
6
|
+
"disclaimer": "ONCORIX is a registered trademark of Example Pharma Inc.",
|
|
7
|
+
"jurisdictions": {
|
|
8
|
+
"FDA": {
|
|
9
|
+
"required_elements": ["isi", "pi_ref", "reporting_statement"],
|
|
10
|
+
"fair_balance_required": true
|
|
11
|
+
},
|
|
12
|
+
"EMA": {
|
|
13
|
+
"required_elements": ["pi_ref", "reporting_statement"],
|
|
14
|
+
"fair_balance_required": true
|
|
15
|
+
},
|
|
16
|
+
"MHRA": {
|
|
17
|
+
"required_elements": ["pi_ref", "reporting_statement"],
|
|
18
|
+
"fair_balance_required": true
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 50" width="200" height="50">
|
|
2
|
+
<rect width="200" height="50" rx="4" fill="#003B5C"/>
|
|
3
|
+
<text x="100" y="33" text-anchor="middle" font-family="Arial, sans-serif" font-size="24" font-weight="bold" fill="#FFFFFF">ONCORIX</text>
|
|
4
|
+
</svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
placeholder — replace with actual product image
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"heading_family": "Arial, Helvetica, sans-serif",
|
|
3
|
+
"body_family": "Arial, Helvetica, sans-serif",
|
|
4
|
+
"heading_weight": "bold",
|
|
5
|
+
"body_weight": "normal",
|
|
6
|
+
"sizes": {
|
|
7
|
+
"h1": "28px",
|
|
8
|
+
"h2": "22px",
|
|
9
|
+
"h3": "18px",
|
|
10
|
+
"body": "14px",
|
|
11
|
+
"small": "11px",
|
|
12
|
+
"legal": "9px"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"claim_id": "c-001",
|
|
4
|
+
"text": "ONCORIX demonstrated a median overall survival of 24.1 months versus 13.6 months with standard of care (HR 0.68; 95% CI 0.55-0.84; p<0.001).",
|
|
5
|
+
"category": "efficacy",
|
|
6
|
+
"source_document": "ONCORIX Approved Product Messages 2026",
|
|
7
|
+
"source_reference": "MERIDIAN-301, Table 1 (Primary Endpoint)",
|
|
8
|
+
"expiry": null,
|
|
9
|
+
"restrictions": null
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"claim_id": "c-002",
|
|
13
|
+
"text": "Overall response rate was 42.3% with ONCORIX versus 16.2% with control (p<0.001).",
|
|
14
|
+
"category": "efficacy",
|
|
15
|
+
"source_document": "ONCORIX Approved Product Messages 2026",
|
|
16
|
+
"source_reference": "MERIDIAN-301, Table 2 (Secondary Endpoints)",
|
|
17
|
+
"expiry": null,
|
|
18
|
+
"restrictions": null
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"claim_id": "c-003",
|
|
22
|
+
"text": "Median progression-free survival was 11.2 months with ONCORIX versus 5.8 months with control (HR 0.58; 95% CI 0.47-0.72; p<0.001).",
|
|
23
|
+
"category": "efficacy",
|
|
24
|
+
"source_document": "ONCORIX Approved Product Messages 2026",
|
|
25
|
+
"source_reference": "MERIDIAN-301, Table 2 (Secondary Endpoints)",
|
|
26
|
+
"expiry": null,
|
|
27
|
+
"restrictions": null
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"claim_id": "c-004",
|
|
31
|
+
"text": "Among responders, 68% maintained response at 12 months.",
|
|
32
|
+
"category": "efficacy",
|
|
33
|
+
"source_document": "ONCORIX Approved Product Messages 2026",
|
|
34
|
+
"source_reference": "MERIDIAN-301, Figure 3 (Duration of Response)",
|
|
35
|
+
"expiry": null,
|
|
36
|
+
"restrictions": null
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"claim_id": "c-005",
|
|
40
|
+
"text": "Consistent overall survival benefit was observed across pre-specified subgroups including PD-L1 status, BRAF mutation status, and prior lines of therapy.",
|
|
41
|
+
"category": "efficacy",
|
|
42
|
+
"source_document": "ONCORIX Approved Product Messages 2026",
|
|
43
|
+
"source_reference": "MERIDIAN-301, Figure 4 (Forest Plot)",
|
|
44
|
+
"expiry": null,
|
|
45
|
+
"restrictions": null
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"claim_id": "c-006",
|
|
49
|
+
"text": "Treatment-related adverse events of any grade occurred in 72.4% of patients receiving ONCORIX versus 64.8% with control.",
|
|
50
|
+
"category": "safety",
|
|
51
|
+
"source_document": "ONCORIX Approved Product Messages 2026",
|
|
52
|
+
"source_reference": "MERIDIAN-301, Table 3 (Safety Summary)",
|
|
53
|
+
"expiry": null,
|
|
54
|
+
"restrictions": null
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"claim_id": "c-007",
|
|
58
|
+
"text": "The most common adverse events (>=15%) with ONCORIX were fatigue (28.1%), rash (19.4%), and diarrhoea (16.7%).",
|
|
59
|
+
"category": "safety",
|
|
60
|
+
"source_document": "ONCORIX Approved Product Messages 2026",
|
|
61
|
+
"source_reference": "MERIDIAN-301, Table 3 (Safety Summary)",
|
|
62
|
+
"expiry": null,
|
|
63
|
+
"restrictions": null
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"claim_id": "c-008",
|
|
67
|
+
"text": "Immune-mediated adverse events occurred in 18.3% of patients, most commonly thyroid disorders (6.2%) and hepatitis (3.1%).",
|
|
68
|
+
"category": "safety",
|
|
69
|
+
"source_document": "ONCORIX Approved Product Messages 2026",
|
|
70
|
+
"source_reference": "MERIDIAN-301, Table 4 (Immune-Mediated AEs)",
|
|
71
|
+
"expiry": null,
|
|
72
|
+
"restrictions": null
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"claim_id": "c-009",
|
|
76
|
+
"text": "Grade 3-4 treatment-related adverse events occurred in 18.2% of ONCORIX patients versus 12.1% with control.",
|
|
77
|
+
"category": "safety",
|
|
78
|
+
"source_document": "ONCORIX Approved Product Messages 2026",
|
|
79
|
+
"source_reference": "MERIDIAN-301, Table 3 (Safety Summary)",
|
|
80
|
+
"expiry": null,
|
|
81
|
+
"restrictions": null
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"claim_id": "c-010",
|
|
85
|
+
"text": "Treatment discontinuation due to adverse events occurred in 8.4% of patients receiving ONCORIX.",
|
|
86
|
+
"category": "safety",
|
|
87
|
+
"source_document": "ONCORIX Approved Product Messages 2026",
|
|
88
|
+
"source_reference": "MERIDIAN-301, Table 3 (Safety Summary)",
|
|
89
|
+
"expiry": null,
|
|
90
|
+
"restrictions": null
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"claim_id": "c-011",
|
|
94
|
+
"text": "ONCORIX (rivolumab) is a humanised monoclonal antibody that selectively blocks the interaction between PD-1 and its ligands PD-L1 and PD-L2, restoring T-cell-mediated anti-tumour immunity.",
|
|
95
|
+
"category": "MOA",
|
|
96
|
+
"source_document": "ONCORIX Approved Product Messages 2026",
|
|
97
|
+
"source_reference": "SmPC Section 5.1 (Mechanism of Action)",
|
|
98
|
+
"expiry": null,
|
|
99
|
+
"restrictions": null
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"claim_id": "c-012",
|
|
103
|
+
"text": "The recommended dose of ONCORIX is 240 mg administered as an intravenous infusion over 30 minutes every 2 weeks.",
|
|
104
|
+
"category": "dosing",
|
|
105
|
+
"source_document": "ONCORIX Approved Product Messages 2026",
|
|
106
|
+
"source_reference": "SmPC Section 4.2 (Posology)",
|
|
107
|
+
"expiry": null,
|
|
108
|
+
"restrictions": null
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"claim_id": "c-013",
|
|
112
|
+
"text": "Treatment with ONCORIX should be continued until disease progression or unacceptable toxicity, for a maximum of 24 months.",
|
|
113
|
+
"category": "dosing",
|
|
114
|
+
"source_document": "ONCORIX Approved Product Messages 2026",
|
|
115
|
+
"source_reference": "SmPC Section 4.2 (Posology)",
|
|
116
|
+
"expiry": null,
|
|
117
|
+
"restrictions": null
|
|
118
|
+
}
|
|
119
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Campaign-studio models — re-exports for convenient imports."""
|
|
2
|
+
|
|
3
|
+
from ._common import SourceReference
|
|
4
|
+
from .brief import CampaignBrief
|
|
5
|
+
from .claims import ApprovedClaim
|
|
6
|
+
from .copy import BannerCopy, ChannelCopy, CopyBlock, EmailCopy, PosterCopy
|
|
7
|
+
from .journey import AudienceJourney, JourneyStage
|
|
8
|
+
from .message import MessageArchitecture, MessageTier
|
|
9
|
+
from .mlr import MlrPackage, RenderedAsset
|
|
10
|
+
from .validation import (
|
|
11
|
+
ClaimValidationReport,
|
|
12
|
+
ClaimValidationResult,
|
|
13
|
+
PolicyCheck,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"ApprovedClaim",
|
|
18
|
+
"AudienceJourney",
|
|
19
|
+
"BannerCopy",
|
|
20
|
+
"CampaignBrief",
|
|
21
|
+
"ChannelCopy",
|
|
22
|
+
"ClaimValidationReport",
|
|
23
|
+
"ClaimValidationResult",
|
|
24
|
+
"CopyBlock",
|
|
25
|
+
"EmailCopy",
|
|
26
|
+
"JourneyStage",
|
|
27
|
+
"MessageArchitecture",
|
|
28
|
+
"MessageTier",
|
|
29
|
+
"MlrPackage",
|
|
30
|
+
"PolicyCheck",
|
|
31
|
+
"PosterCopy",
|
|
32
|
+
"RenderedAsset",
|
|
33
|
+
"SourceReference",
|
|
34
|
+
]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Shared types used across campaign-studio models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SourceReference(BaseModel):
|
|
9
|
+
document_id: str = Field(description="Identifier of the approved source document")
|
|
10
|
+
document_name: str = Field(description="Original filename")
|
|
11
|
+
page_number: int | None = Field(default=None, description="1-indexed page or slide number, if applicable")
|
|
12
|
+
excerpt: str = Field(description="Verbatim passage from the source")
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Campaign brief models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CampaignBrief(BaseModel):
|
|
9
|
+
campaign_brief_id: str = Field(description="Unique reusable identifier")
|
|
10
|
+
campaign_name: str = Field(description="Short campaign name")
|
|
11
|
+
|
|
12
|
+
# Jurisdiction & mode
|
|
13
|
+
country: str = Field(description="ISO 3166-1 alpha-2 country code")
|
|
14
|
+
policy_jurisdiction: str = Field(description="Regulatory jurisdiction: FDA | EMA | MHRA | HSA | PMDA | TGA")
|
|
15
|
+
mode: str = Field(description="promotional | non_promotional | disease_awareness")
|
|
16
|
+
|
|
17
|
+
# Product
|
|
18
|
+
brand: str = Field(description="Brand name")
|
|
19
|
+
indication: str = Field(description="Approved indication")
|
|
20
|
+
lifecycle_stage: str = Field(
|
|
21
|
+
default="growth",
|
|
22
|
+
description="pre_launch | launch | growth | mature | LOE",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Audience & objectives
|
|
26
|
+
target_segment: str = Field(description="e.g. oncologists, PCPs, nurses, patients")
|
|
27
|
+
behavioral_objective: str = Field(description="What the audience should DO after exposure")
|
|
28
|
+
educational_objective: str | None = Field(default=None)
|
|
29
|
+
desired_kpi: list[str] = Field(description="e.g. ['open_rate>25%', 'HCP_reach_500']")
|
|
30
|
+
|
|
31
|
+
# Content sources
|
|
32
|
+
approved_claims_path: str | None = Field(
|
|
33
|
+
default=None,
|
|
34
|
+
description="Path to claims JSON or PDF",
|
|
35
|
+
)
|
|
36
|
+
call_to_action: str = Field(description="Primary CTA text")
|
|
37
|
+
|
|
38
|
+
# Channels
|
|
39
|
+
channels: list[str] = Field(description="['email', 'banner', 'poster']")
|
|
40
|
+
asset_dimensions: dict | None = Field(
|
|
41
|
+
default=None,
|
|
42
|
+
description='{"banner": "728x90", "poster": "A4"}',
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Brand
|
|
46
|
+
brand_kit_path: str | None = Field(
|
|
47
|
+
default=None,
|
|
48
|
+
description="Path to brand kit directory; uses fixture if omitted",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Localisation
|
|
52
|
+
language: str = Field(default="en")
|
|
53
|
+
localisation_notes: str | None = Field(default=None)
|
|
54
|
+
|
|
55
|
+
# Compliance
|
|
56
|
+
required_safety_content: list[str] = Field(
|
|
57
|
+
default_factory=list,
|
|
58
|
+
description="ISI, boxed warning, PI reference",
|
|
59
|
+
)
|
|
60
|
+
required_legal_content: list[str] = Field(
|
|
61
|
+
default_factory=list,
|
|
62
|
+
description="Copyright, disclaimer, reporting statement",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Delivery
|
|
66
|
+
delivery_constraints: str | None = Field(default=None)
|
|
67
|
+
approval_workflow: str = Field(
|
|
68
|
+
default="mlr_standard",
|
|
69
|
+
description="mlr_standard | mlr_expedited | medical_only",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
generated_at: str = Field(description="ISO 8601 timestamp")
|