open-pharma-plugins 2.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. mcp_framework.py +495 -0
  2. open_pharma_plugins-2.2.0.dist-info/METADATA +135 -0
  3. open_pharma_plugins-2.2.0.dist-info/RECORD +136 -0
  4. open_pharma_plugins-2.2.0.dist-info/WHEEL +5 -0
  5. open_pharma_plugins-2.2.0.dist-info/entry_points.txt +8 -0
  6. open_pharma_plugins-2.2.0.dist-info/licenses/LICENSE +202 -0
  7. open_pharma_plugins-2.2.0.dist-info/top_level.txt +8 -0
  8. open_pharma_plugins_campaign_studio/__init__.py +14 -0
  9. open_pharma_plugins_campaign_studio/__main__.py +11 -0
  10. open_pharma_plugins_campaign_studio/_campaign_store.py +162 -0
  11. open_pharma_plugins_campaign_studio/_claim_engine.py +262 -0
  12. open_pharma_plugins_campaign_studio/_renderer.py +119 -0
  13. open_pharma_plugins_campaign_studio/fixtures/brand_kit/legal.json +21 -0
  14. open_pharma_plugins_campaign_studio/fixtures/brand_kit/logo.svg +4 -0
  15. open_pharma_plugins_campaign_studio/fixtures/brand_kit/palette.json +11 -0
  16. open_pharma_plugins_campaign_studio/fixtures/brand_kit/product.png +1 -0
  17. open_pharma_plugins_campaign_studio/fixtures/brand_kit/typography.json +14 -0
  18. open_pharma_plugins_campaign_studio/fixtures/sample_approved_claims.json +119 -0
  19. open_pharma_plugins_campaign_studio/models/__init__.py +34 -0
  20. open_pharma_plugins_campaign_studio/models/_common.py +12 -0
  21. open_pharma_plugins_campaign_studio/models/brief.py +72 -0
  22. open_pharma_plugins_campaign_studio/models/claims.py +15 -0
  23. open_pharma_plugins_campaign_studio/models/copy.py +47 -0
  24. open_pharma_plugins_campaign_studio/models/journey.py +21 -0
  25. open_pharma_plugins_campaign_studio/models/message.py +25 -0
  26. open_pharma_plugins_campaign_studio/models/mlr.py +29 -0
  27. open_pharma_plugins_campaign_studio/models/validation.py +32 -0
  28. open_pharma_plugins_campaign_studio/policy/rules.json +79 -0
  29. open_pharma_plugins_campaign_studio/templates/banner.svg.j2 +26 -0
  30. open_pharma_plugins_campaign_studio/templates/email.html.j2 +54 -0
  31. open_pharma_plugins_campaign_studio/tools/__init__.py +0 -0
  32. open_pharma_plugins_campaign_studio/tools/create_campaign_brief.py +212 -0
  33. open_pharma_plugins_campaign_studio/tools/generate_audience_journey.py +129 -0
  34. open_pharma_plugins_campaign_studio/tools/generate_channel_copy.py +199 -0
  35. open_pharma_plugins_campaign_studio/tools/generate_message_architecture.py +121 -0
  36. open_pharma_plugins_campaign_studio/tools/package_mlr_submission.py +230 -0
  37. open_pharma_plugins_campaign_studio/tools/render_banner.py +99 -0
  38. open_pharma_plugins_campaign_studio/tools/render_email.py +101 -0
  39. open_pharma_plugins_campaign_studio/tools/render_poster.py +222 -0
  40. open_pharma_plugins_campaign_studio/tools/retrieve_approved_claims.py +71 -0
  41. open_pharma_plugins_campaign_studio/tools/retrieve_brand_components.py +76 -0
  42. open_pharma_plugins_campaign_studio/tools/validate_claims_and_fair_balance.py +285 -0
  43. open_pharma_plugins_competitive_intelligence/__init__.py +13 -0
  44. open_pharma_plugins_competitive_intelligence/__main__.py +11 -0
  45. open_pharma_plugins_competitive_intelligence/_artifacts.py +87 -0
  46. open_pharma_plugins_competitive_intelligence/_cache.py +144 -0
  47. open_pharma_plugins_competitive_intelligence/_clinical_trials.py +569 -0
  48. open_pharma_plugins_competitive_intelligence/_dailymed.py +260 -0
  49. open_pharma_plugins_competitive_intelligence/_fda.py +255 -0
  50. open_pharma_plugins_competitive_intelligence/_pubmed.py +342 -0
  51. open_pharma_plugins_competitive_intelligence/_regulatory.py +140 -0
  52. open_pharma_plugins_competitive_intelligence/_runs.py +278 -0
  53. open_pharma_plugins_competitive_intelligence/_transport.py +83 -0
  54. open_pharma_plugins_competitive_intelligence/_watchlist.py +113 -0
  55. open_pharma_plugins_competitive_intelligence/_web_search.py +331 -0
  56. open_pharma_plugins_competitive_intelligence/models.py +525 -0
  57. open_pharma_plugins_competitive_intelligence/tools/__init__.py +0 -0
  58. open_pharma_plugins_competitive_intelligence/tools/ci_extract_events.py +247 -0
  59. open_pharma_plugins_competitive_intelligence/tools/ci_landscape.py +195 -0
  60. open_pharma_plugins_competitive_intelligence/tools/ci_refresh.py +101 -0
  61. open_pharma_plugins_competitive_intelligence/tools/ci_report.py +402 -0
  62. open_pharma_plugins_competitive_intelligence/tools/ci_scan_news.py +48 -0
  63. open_pharma_plugins_competitive_intelligence/tools/ci_scan_publications.py +46 -0
  64. open_pharma_plugins_competitive_intelligence/tools/ci_scan_regulatory.py +64 -0
  65. open_pharma_plugins_competitive_intelligence/tools/ci_scan_trials.py +85 -0
  66. open_pharma_plugins_competitive_intelligence/tools/ci_status.py +106 -0
  67. open_pharma_plugins_competitive_intelligence/tools/ci_timeline.py +453 -0
  68. open_pharma_plugins_competitive_intelligence/tools/ci_track.py +121 -0
  69. open_pharma_plugins_competitive_intelligence/tools/ci_trial_detail.py +55 -0
  70. open_pharma_plugins_field_training/__init__.py +13 -0
  71. open_pharma_plugins_field_training/__main__.py +11 -0
  72. open_pharma_plugins_field_training/_content_store.py +131 -0
  73. open_pharma_plugins_field_training/_grounding.py +75 -0
  74. open_pharma_plugins_field_training/_html_renderers.py +546 -0
  75. open_pharma_plugins_field_training/fixtures/sample_product_message.pdf +156 -0
  76. open_pharma_plugins_field_training/fixtures/sample_training_deck.pptx +0 -0
  77. open_pharma_plugins_field_training/models.py +265 -0
  78. open_pharma_plugins_field_training/tools/__init__.py +0 -0
  79. open_pharma_plugins_field_training/tools/get_document_page.py +67 -0
  80. open_pharma_plugins_field_training/tools/ingest_document.py +147 -0
  81. open_pharma_plugins_field_training/tools/list_documents.py +51 -0
  82. open_pharma_plugins_field_training/tools/render_output.py +118 -0
  83. open_pharma_plugins_field_training/tools/search_content.py +57 -0
  84. open_pharma_plugins_hcp_intelligence/__init__.py +14 -0
  85. open_pharma_plugins_hcp_intelligence/__main__.py +11 -0
  86. open_pharma_plugins_hcp_intelligence/_crm_store.py +70 -0
  87. open_pharma_plugins_hcp_intelligence/batch.py +891 -0
  88. open_pharma_plugins_hcp_intelligence/batch_cli.py +221 -0
  89. open_pharma_plugins_hcp_intelligence/batch_csv.py +206 -0
  90. open_pharma_plugins_hcp_intelligence/fixtures/sample_accounts.csv +27 -0
  91. open_pharma_plugins_hcp_intelligence/models.py +356 -0
  92. open_pharma_plugins_hcp_intelligence/tools/__init__.py +0 -0
  93. open_pharma_plugins_hcp_intelligence/tools/get_account.py +48 -0
  94. open_pharma_plugins_hcp_intelligence/tools/list_accounts.py +66 -0
  95. open_pharma_plugins_hcp_intelligence/tools/search_clinical_trials.py +175 -0
  96. open_pharma_plugins_hcp_intelligence/tools/search_congresses.py +134 -0
  97. open_pharma_plugins_hcp_intelligence/tools/search_grants.py +181 -0
  98. open_pharma_plugins_hcp_intelligence/tools/search_guidelines.py +238 -0
  99. open_pharma_plugins_hcp_intelligence/tools/search_hco_web.py +73 -0
  100. open_pharma_plugins_hcp_intelligence/tools/search_hcp_web.py +199 -0
  101. open_pharma_plugins_hcp_intelligence/tools/search_orcid.py +214 -0
  102. open_pharma_plugins_hcp_intelligence/tools/search_publications.py +207 -0
  103. open_pharma_plugins_hcp_intelligence/tools/update_account.py +84 -0
  104. open_pharma_plugins_next_best_engagement/__init__.py +14 -0
  105. open_pharma_plugins_next_best_engagement/__main__.py +11 -0
  106. open_pharma_plugins_next_best_engagement/_optimizer.py +400 -0
  107. open_pharma_plugins_next_best_engagement/_renderer.py +149 -0
  108. open_pharma_plugins_next_best_engagement/_scoring.py +82 -0
  109. open_pharma_plugins_next_best_engagement/_universe.py +145 -0
  110. open_pharma_plugins_next_best_engagement/fixtures/sample_universe.csv +81 -0
  111. open_pharma_plugins_next_best_engagement/models.py +135 -0
  112. open_pharma_plugins_next_best_engagement/tools/__init__.py +0 -0
  113. open_pharma_plugins_next_best_engagement/tools/load_universe.py +47 -0
  114. open_pharma_plugins_next_best_engagement/tools/recommend_engagements.py +80 -0
  115. open_pharma_plugins_next_best_engagement/tools/render_plan.py +90 -0
  116. open_pharma_plugins_territory_alignment/__init__.py +14 -0
  117. open_pharma_plugins_territory_alignment/__main__.py +11 -0
  118. open_pharma_plugins_territory_alignment/data.py +300 -0
  119. open_pharma_plugins_territory_alignment/fixtures/constraints.csv +11 -0
  120. open_pharma_plugins_territory_alignment/fixtures/current_alignment.csv +81 -0
  121. open_pharma_plugins_territory_alignment/fixtures/hcps.csv +81 -0
  122. open_pharma_plugins_territory_alignment/fixtures/reps.csv +9 -0
  123. open_pharma_plugins_territory_alignment/geo.py +175 -0
  124. open_pharma_plugins_territory_alignment/models.py +201 -0
  125. open_pharma_plugins_territory_alignment/scoring.py +125 -0
  126. open_pharma_plugins_territory_alignment/solver.py +504 -0
  127. open_pharma_plugins_territory_alignment/tools/__init__.py +0 -0
  128. open_pharma_plugins_territory_alignment/tools/ta_align.py +138 -0
  129. open_pharma_plugins_territory_alignment/tools/ta_cluster.py +247 -0
  130. open_pharma_plugins_territory_alignment/tools/ta_compare.py +173 -0
  131. open_pharma_plugins_territory_alignment/tools/ta_evaluate.py +119 -0
  132. open_pharma_plugins_territory_alignment/tools/ta_status.py +34 -0
  133. open_pharma_plugins_territory_alignment/tools/ta_visualize.py +504 -0
  134. shared/__init__.py +11 -0
  135. shared/env.py +217 -0
  136. shared/filesystem.py +110 -0
@@ -0,0 +1,129 @@
1
+ """generate_audience_journey — validate and persist an audience journey."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class JourneyStageInput(BaseModel):
11
+ stage: str = Field(description="unaware | aware | interested | convinced | acting | advocating")
12
+ objective: str = Field(description="What should happen at this stage")
13
+ key_messages: list[str] = Field(description="claim_id references")
14
+ channels: list[str] = Field(description="Channels serving this stage")
15
+ content_type: str = Field(description="educational | promotional | reminder")
16
+ kpi: str = Field(description="Measurable outcome for this stage")
17
+
18
+
19
+ class GenerateAudienceJourneyArgs(BaseModel):
20
+ campaign_brief_id: str = Field(description="Links to the campaign brief")
21
+ journey: list[JourneyStageInput] = Field(description="Ordered list of journey stages")
22
+
23
+
24
+ TOOL: dict[str, Any] = {
25
+ "name": "generate_audience_journey",
26
+ "description": (
27
+ "Validate and persist an audience journey for a campaign. Checks that "
28
+ "every channel referenced exists in the brief's channel list, and "
29
+ "every claim_id in key_messages exists in the approved claims set. "
30
+ "The agent generates the journey content; this tool validates and saves."
31
+ ),
32
+ "args": GenerateAudienceJourneyArgs,
33
+ }
34
+
35
+ _VALID_STAGES = {
36
+ "unaware",
37
+ "aware",
38
+ "interested",
39
+ "convinced",
40
+ "acting",
41
+ "advocating",
42
+ }
43
+ _VALID_CONTENT_TYPES = {"educational", "promotional", "reminder"}
44
+
45
+
46
+ def handle(arguments: dict[str, Any]) -> list[dict[str, Any]]:
47
+ import json
48
+ from datetime import datetime, timezone
49
+
50
+ from .._campaign_store import load_artifact, load_brief, save_artifact
51
+
52
+ campaign_brief_id = arguments["campaign_brief_id"]
53
+ stages = arguments["journey"]
54
+
55
+ brief = load_brief(campaign_brief_id)
56
+ if not brief:
57
+ return [
58
+ {
59
+ "type": "text",
60
+ "text": json.dumps(
61
+ {"error": f"Campaign brief '{campaign_brief_id}' not found. Run create_campaign_brief first."}
62
+ ),
63
+ }
64
+ ]
65
+
66
+ claims_data = load_artifact(campaign_brief_id, "approved-claims.json")
67
+ valid_claim_ids: set[str] = set()
68
+ if claims_data and isinstance(claims_data, list):
69
+ valid_claim_ids = {c["claim_id"] for c in claims_data if "claim_id" in c}
70
+
71
+ brief_channels = set(brief.get("channels", []))
72
+ errors: list[str] = []
73
+ warnings: list[str] = []
74
+
75
+ for i, stage in enumerate(stages):
76
+ s = stage if isinstance(stage, dict) else stage
77
+ stage_name = s.get("stage", "")
78
+ if stage_name not in _VALID_STAGES:
79
+ errors.append(
80
+ f"Stage {i + 1}: invalid stage '{stage_name}'. Must be one of: {', '.join(sorted(_VALID_STAGES))}"
81
+ )
82
+
83
+ content_type = s.get("content_type", "")
84
+ if content_type not in _VALID_CONTENT_TYPES:
85
+ errors.append(
86
+ f"Stage {i + 1}: invalid content_type '{content_type}'. "
87
+ f"Must be one of: {', '.join(sorted(_VALID_CONTENT_TYPES))}"
88
+ )
89
+
90
+ invalid_channels = set(s.get("channels", [])) - brief_channels
91
+ if invalid_channels:
92
+ errors.append(
93
+ f"Stage {i + 1}: channels {sorted(invalid_channels)} not in brief. Available: {sorted(brief_channels)}"
94
+ )
95
+
96
+ if valid_claim_ids:
97
+ unknown_claims = set(s.get("key_messages", [])) - valid_claim_ids
98
+ if unknown_claims:
99
+ errors.append(f"Stage {i + 1}: claim_ids {sorted(unknown_claims)} not found in approved claims.")
100
+ elif s.get("key_messages"):
101
+ warnings.append(
102
+ f"Stage {i + 1}: cannot validate claim_ids — no approved-claims.json found. "
103
+ "Run retrieve_approved_claims first."
104
+ )
105
+
106
+ if errors:
107
+ return [
108
+ {
109
+ "type": "text",
110
+ "text": json.dumps({"errors": errors, "warnings": warnings}),
111
+ }
112
+ ]
113
+
114
+ journey = {
115
+ "campaign_brief_id": campaign_brief_id,
116
+ "target_segment": brief.get("target_segment", ""),
117
+ "stages": stages if isinstance(stages[0], dict) else [s for s in stages],
118
+ "generated_at": datetime.now(timezone.utc).isoformat(),
119
+ }
120
+
121
+ save_artifact(campaign_brief_id, "audience-journey.json", journey)
122
+
123
+ result = {
124
+ "campaign_brief_id": campaign_brief_id,
125
+ "total_stages": len(stages),
126
+ "stages": [s.get("stage", "") if isinstance(s, dict) else s for s in stages],
127
+ "warnings": warnings,
128
+ }
129
+ return [{"type": "text", "text": json.dumps(result, indent=2)}]
@@ -0,0 +1,199 @@
1
+ """generate_channel_copy — validate and persist structured copy for a channel."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class GenerateChannelCopyArgs(BaseModel):
11
+ campaign_brief_id: str = Field(description="Links to the campaign brief")
12
+ channel: str = Field(description="email | banner | poster")
13
+ copy_json: str = Field(
14
+ description=(
15
+ "JSON string of the channel-specific copy. For email: "
16
+ '{"subject": {"text": "...", "claim_ids": []}, "preheader": {...}, '
17
+ '"headline": {...}, "body": [{...}], "cta": {...}}. '
18
+ "For banner: {headline, sub_headline?, cta}. "
19
+ "For poster: {headline, subhead?, body, bullet_points?, cta, footnotes?}."
20
+ )
21
+ )
22
+
23
+
24
+ TOOL: dict[str, Any] = {
25
+ "name": "generate_channel_copy",
26
+ "description": (
27
+ "Validate and persist structured copy for a specific channel. Accepts "
28
+ "a JSON string with channel-specific copy blocks (EmailCopy, BannerCopy, "
29
+ "or PosterCopy). Validates that claim_ids exist in the approved claims "
30
+ "set, enforces character limits per channel, and checks the CTA matches "
31
+ "the brief. The agent generates the copy; this tool validates and saves."
32
+ ),
33
+ "args": GenerateChannelCopyArgs,
34
+ }
35
+
36
+ _VALID_CHANNELS = {"email", "banner", "poster"}
37
+
38
+
39
+ def handle(arguments: dict[str, Any]) -> list[dict[str, Any]]:
40
+ import json
41
+ from datetime import datetime, timezone
42
+
43
+ from .._campaign_store import load_artifact, load_brief, save_artifact
44
+
45
+ campaign_brief_id = arguments["campaign_brief_id"]
46
+ channel = arguments["channel"]
47
+ copy_json_str = arguments["copy_json"]
48
+
49
+ if channel not in _VALID_CHANNELS:
50
+ return [
51
+ {
52
+ "type": "text",
53
+ "text": json.dumps(
54
+ {"error": f"Invalid channel '{channel}'. Must be one of: {', '.join(sorted(_VALID_CHANNELS))}"}
55
+ ),
56
+ }
57
+ ]
58
+
59
+ brief = load_brief(campaign_brief_id)
60
+ if not brief:
61
+ return [
62
+ {
63
+ "type": "text",
64
+ "text": json.dumps({"error": f"Campaign brief '{campaign_brief_id}' not found."}),
65
+ }
66
+ ]
67
+
68
+ if channel not in brief.get("channels", []):
69
+ return [
70
+ {
71
+ "type": "text",
72
+ "text": json.dumps(
73
+ {"error": f"Channel '{channel}' not in brief's channels: {brief.get('channels', [])}"}
74
+ ),
75
+ }
76
+ ]
77
+
78
+ try:
79
+ copy_data = json.loads(copy_json_str)
80
+ except json.JSONDecodeError as e:
81
+ return [{"type": "text", "text": json.dumps({"error": f"Invalid JSON: {e}"})}]
82
+
83
+ from pydantic import ValidationError
84
+
85
+ from ..models.copy import BannerCopy, EmailCopy, PosterCopy
86
+
87
+ copy_models = {"email": EmailCopy, "banner": BannerCopy, "poster": PosterCopy}
88
+ try:
89
+ copy_data = copy_models[channel].model_validate(copy_data).model_dump(mode="json", exclude_none=True)
90
+ except ValidationError as exc:
91
+ return [{"type": "text", "text": json.dumps({"errors": [f"Invalid {channel} copy: {exc}"]})}]
92
+
93
+ claims_data = load_artifact(campaign_brief_id, "approved-claims.json")
94
+ valid_claim_ids: set[str] = set()
95
+ if claims_data and isinstance(claims_data, list):
96
+ valid_claim_ids = {c["claim_id"] for c in claims_data if "claim_id" in c}
97
+
98
+ errors: list[str] = []
99
+ warnings: list[str] = []
100
+
101
+ all_blocks = _extract_copy_blocks(copy_data, channel)
102
+
103
+ from .._claim_engine import is_claim_citation_exempt
104
+ from .._renderer import load_brand_kit
105
+
106
+ legal = load_brand_kit(campaign_brief_id).get("legal", {})
107
+ promotional = brief.get("mode") == "promotional"
108
+ for block_name, block in all_blocks:
109
+ text = block.get("text", "").strip()
110
+ claim_ids = block.get("claim_ids", [])
111
+ unknown = set(claim_ids) - valid_claim_ids
112
+ if unknown:
113
+ errors.append(f"{block_name}: claim_ids {sorted(unknown)} not found in approved claims.")
114
+ if promotional and text and not claim_ids and not is_claim_citation_exempt(block_name, text, brief, legal):
115
+ errors.append(f"{block_name}: promotional copy must cite at least one approved claim ID.")
116
+
117
+ if any(block.get("claim_ids") for _, block in all_blocks) and not claims_data:
118
+ errors.append("Cannot validate claim_ids — run retrieve_approved_claims first.")
119
+
120
+ cta = copy_data.get("cta", {}).get("text", "")
121
+ if cta.strip().casefold() != brief.get("call_to_action", "").strip().casefold():
122
+ errors.append("cta: text must exactly match the campaign brief call_to_action.")
123
+
124
+ for index, footnote in enumerate(copy_data.get("footnotes", [])):
125
+ if promotional and footnote.strip() and not is_claim_citation_exempt("footnote", footnote, brief, legal):
126
+ errors.append(f"footnotes[{index}]: promotional footnotes must be verbatim approved legal text.")
127
+
128
+ if channel == "banner":
129
+ headline = copy_data.get("headline", {})
130
+ headline_text = headline.get("text", "")
131
+ word_count = len(headline_text.split())
132
+ if word_count > 8:
133
+ warnings.append(f"Banner headline has {word_count} words (recommended max: 8).")
134
+
135
+ cta = copy_data.get("cta", {})
136
+ cta_text = cta.get("text", "")
137
+ cta_words = len(cta_text.split())
138
+ if cta_words > 3:
139
+ warnings.append(f"Banner CTA has {cta_words} words (recommended max: 3).")
140
+
141
+ if errors:
142
+ return [
143
+ {
144
+ "type": "text",
145
+ "text": json.dumps({"errors": errors, "warnings": warnings}),
146
+ }
147
+ ]
148
+
149
+ artifact = {
150
+ "campaign_brief_id": campaign_brief_id,
151
+ "channel": channel,
152
+ "copy": copy_data,
153
+ "generated_at": datetime.now(timezone.utc).isoformat(),
154
+ }
155
+
156
+ save_artifact(campaign_brief_id, f"copy-{channel}.json", artifact)
157
+
158
+ result = {
159
+ "campaign_brief_id": campaign_brief_id,
160
+ "channel": channel,
161
+ "total_blocks": len(all_blocks),
162
+ "warnings": warnings,
163
+ }
164
+ return [{"type": "text", "text": json.dumps(result, indent=2)}]
165
+
166
+
167
+ def _extract_copy_blocks(copy_data: dict, channel: str) -> list[tuple[str, dict]]:
168
+ """Extract named copy blocks from channel-specific copy data."""
169
+ blocks: list[tuple[str, dict]] = []
170
+
171
+ simple_fields = {
172
+ "email": [
173
+ "subject",
174
+ "preheader",
175
+ "headline",
176
+ "cta",
177
+ ],
178
+ "banner": ["headline", "sub_headline", "cta"],
179
+ "poster": ["headline", "subhead", "cta"],
180
+ }
181
+
182
+ for field in simple_fields.get(channel, []):
183
+ val = copy_data.get(field)
184
+ if val and isinstance(val, dict):
185
+ blocks.append((field, val))
186
+
187
+ list_fields = {
188
+ "email": ["body"],
189
+ "poster": ["body", "bullet_points"],
190
+ }
191
+
192
+ for field in list_fields.get(channel, []):
193
+ items = copy_data.get(field)
194
+ if items and isinstance(items, list):
195
+ for i, item in enumerate(items):
196
+ if isinstance(item, dict):
197
+ blocks.append((f"{field}[{i}]", item))
198
+
199
+ return blocks
@@ -0,0 +1,121 @@
1
+ """generate_message_architecture — validate and persist a message hierarchy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class SourceReferenceInput(BaseModel):
11
+ document_id: str = Field(description="Source document identifier")
12
+ document_name: str = Field(description="Original filename")
13
+ page_number: int | None = Field(default=None)
14
+ excerpt: str = Field(description="Verbatim passage from the source")
15
+
16
+
17
+ class MessageTierInput(BaseModel):
18
+ tier: str = Field(description="primary | secondary | supporting")
19
+ message: str = Field(description="The message statement")
20
+ claim_ids: list[str] = Field(description="Approved claim IDs this message draws from")
21
+ audience_stage: str | None = Field(default=None, description="Links to a journey stage")
22
+ rationale: str = Field(description="Why this message matters for the objective")
23
+
24
+
25
+ class GenerateMessageArchitectureArgs(BaseModel):
26
+ campaign_brief_id: str = Field(description="Links to the campaign brief")
27
+ messages: list[MessageTierInput] = Field(description="Tiered message hierarchy")
28
+ fair_balance_statement: str = Field(description="Required safety/risk language")
29
+ fair_balance_sources: list[SourceReferenceInput] = Field(description="Sources for the fair balance statement")
30
+
31
+
32
+ TOOL: dict[str, Any] = {
33
+ "name": "generate_message_architecture",
34
+ "description": (
35
+ "Validate and persist a three-tier message architecture (primary, "
36
+ "secondary, supporting). Checks that claim_ids exist in the approved "
37
+ "claims set, at least one primary message is present, and fair balance "
38
+ "is included. The agent generates the messages; this tool validates and saves."
39
+ ),
40
+ "args": GenerateMessageArchitectureArgs,
41
+ }
42
+
43
+ _VALID_TIERS = {"primary", "secondary", "supporting"}
44
+
45
+
46
+ def handle(arguments: dict[str, Any]) -> list[dict[str, Any]]:
47
+ import json
48
+ from datetime import datetime, timezone
49
+
50
+ from .._campaign_store import load_artifact, load_brief, save_artifact
51
+
52
+ campaign_brief_id = arguments["campaign_brief_id"]
53
+ messages = arguments["messages"]
54
+ fair_balance = arguments["fair_balance_statement"]
55
+ fair_balance_sources = arguments.get("fair_balance_sources", [])
56
+
57
+ brief = load_brief(campaign_brief_id)
58
+ if not brief:
59
+ return [
60
+ {
61
+ "type": "text",
62
+ "text": json.dumps(
63
+ {"error": f"Campaign brief '{campaign_brief_id}' not found. Run create_campaign_brief first."}
64
+ ),
65
+ }
66
+ ]
67
+
68
+ claims_data = load_artifact(campaign_brief_id, "approved-claims.json")
69
+ valid_claim_ids: set[str] = set()
70
+ if claims_data and isinstance(claims_data, list):
71
+ valid_claim_ids = {c["claim_id"] for c in claims_data if "claim_id" in c}
72
+
73
+ errors: list[str] = []
74
+
75
+ has_primary = False
76
+ for i, msg in enumerate(messages):
77
+ m = msg if isinstance(msg, dict) else msg
78
+ tier = m.get("tier", "")
79
+ if tier not in _VALID_TIERS:
80
+ errors.append(f"Message {i + 1}: invalid tier '{tier}'. Must be one of: {', '.join(sorted(_VALID_TIERS))}")
81
+ if tier == "primary":
82
+ has_primary = True
83
+
84
+ if valid_claim_ids:
85
+ unknown = set(m.get("claim_ids", [])) - valid_claim_ids
86
+ if unknown:
87
+ errors.append(f"Message {i + 1}: claim_ids {sorted(unknown)} not found in approved claims.")
88
+
89
+ if not has_primary:
90
+ errors.append("At least one message must have tier 'primary'.")
91
+
92
+ if not fair_balance.strip():
93
+ errors.append("fair_balance_statement is required.")
94
+
95
+ if errors:
96
+ return [{"type": "text", "text": json.dumps({"errors": errors})}]
97
+
98
+ architecture = {
99
+ "campaign_brief_id": campaign_brief_id,
100
+ "brand": brief.get("brand", ""),
101
+ "indication": brief.get("indication", ""),
102
+ "message_tiers": messages if isinstance(messages[0], dict) else [m for m in messages],
103
+ "fair_balance_statement": fair_balance,
104
+ "fair_balance_sources": fair_balance_sources if isinstance(fair_balance_sources, list) else [],
105
+ "generated_at": datetime.now(timezone.utc).isoformat(),
106
+ }
107
+
108
+ save_artifact(campaign_brief_id, "message-architecture.json", architecture)
109
+
110
+ tier_counts = {}
111
+ for m in messages:
112
+ t = m.get("tier", "") if isinstance(m, dict) else m
113
+ tier_counts[t] = tier_counts.get(t, 0) + 1
114
+
115
+ result = {
116
+ "campaign_brief_id": campaign_brief_id,
117
+ "total_messages": len(messages),
118
+ "tier_counts": tier_counts,
119
+ "fair_balance_included": True,
120
+ }
121
+ return [{"type": "text", "text": json.dumps(result, indent=2)}]
@@ -0,0 +1,230 @@
1
+ """package_mlr_submission — assemble the full MLR review package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class PackageMlrSubmissionArgs(BaseModel):
11
+ campaign_brief_id: str = Field(description="Links to the campaign")
12
+ reviewer_notes: str | None = Field(
13
+ default=None,
14
+ description="Additional context for reviewers",
15
+ )
16
+
17
+
18
+ TOOL: dict[str, Any] = {
19
+ "name": "package_mlr_submission",
20
+ "description": (
21
+ "Assemble the full MLR (Medical-Legal-Regulatory) review package for "
22
+ "a campaign. Collects all generated artifacts — brief, claims, journey, "
23
+ "message architecture, channel copies, validation reports, and rendered "
24
+ "assets — into the campaign directory and generates a human-readable "
25
+ "mlr-review-summary.md cover sheet. Requires that validation has passed."
26
+ ),
27
+ "args": PackageMlrSubmissionArgs,
28
+ }
29
+
30
+
31
+ def handle(arguments: dict[str, Any]) -> list[dict[str, Any]]:
32
+ import json
33
+ from datetime import datetime, timezone
34
+
35
+ from .._campaign_store import (
36
+ campaign_dir,
37
+ load_brief,
38
+ load_validation_artifact,
39
+ outputs_dir,
40
+ save_output,
41
+ )
42
+ from .._renderer import check_validation_gate
43
+
44
+ campaign_brief_id = arguments["campaign_brief_id"]
45
+ reviewer_notes = arguments.get("reviewer_notes")
46
+
47
+ gate_error = check_validation_gate(campaign_brief_id)
48
+ if gate_error:
49
+ return [{"type": "text", "text": json.dumps({"error": gate_error})}]
50
+
51
+ brief = load_brief(campaign_brief_id)
52
+ if not brief:
53
+ return [
54
+ {
55
+ "type": "text",
56
+ "text": json.dumps({"error": f"Campaign brief '{campaign_brief_id}' not found."}),
57
+ }
58
+ ]
59
+
60
+ # Collect all artifacts
61
+ artifacts: dict[str, str] = {}
62
+ cdir = campaign_dir(campaign_brief_id)
63
+ odir = outputs_dir(campaign_brief_id)
64
+
65
+ artifact_files = [
66
+ "campaign-brief.json",
67
+ "approved-claims.json",
68
+ "audience-journey.json",
69
+ "message-architecture.json",
70
+ ]
71
+ for channel in brief.get("channels", []):
72
+ artifact_files.append(f"copy-{channel}.json")
73
+
74
+ for fname in artifact_files:
75
+ path = cdir / fname
76
+ if path.exists():
77
+ artifacts[fname] = "present"
78
+ else:
79
+ artifacts[fname] = "MISSING"
80
+
81
+ validation_files = [
82
+ "claim-map.json",
83
+ "policy-checks.json",
84
+ "source-evidence.json",
85
+ ]
86
+ vdir = cdir / "validation"
87
+ for fname in validation_files:
88
+ path = vdir / fname
89
+ if path.exists():
90
+ artifacts[f"validation/{fname}"] = "present"
91
+ else:
92
+ artifacts[f"validation/{fname}"] = "MISSING"
93
+
94
+ output_files = []
95
+ for channel in brief.get("channels", []):
96
+ ext_map = {"email": "html", "banner": "svg", "poster": "pdf"}
97
+ ext = ext_map.get(channel, "html")
98
+ fname = f"{channel}.{ext}"
99
+ path = odir / fname
100
+ if path.exists():
101
+ output_files.append(fname)
102
+ artifacts[f"outputs/{fname}"] = "present"
103
+ else:
104
+ artifacts[f"outputs/{fname}"] = "MISSING"
105
+
106
+ # Load validation report for summary
107
+ validation = load_validation_artifact(campaign_brief_id, "policy-checks.json")
108
+ source_evidence = load_validation_artifact(campaign_brief_id, "source-evidence.json")
109
+ claim_map = load_validation_artifact(campaign_brief_id, "claim-map.json")
110
+
111
+ # Generate MLR review summary markdown
112
+ md = _build_summary_md(
113
+ brief,
114
+ validation,
115
+ source_evidence,
116
+ claim_map,
117
+ output_files,
118
+ artifacts,
119
+ reviewer_notes,
120
+ )
121
+
122
+ summary_path = save_output(campaign_brief_id, "mlr-review-summary.md", md)
123
+
124
+ missing = [k for k, v in artifacts.items() if v == "MISSING"]
125
+
126
+ result = {
127
+ "campaign_brief_id": campaign_brief_id,
128
+ "package_directory": str(cdir),
129
+ "mlr_summary_path": str(summary_path),
130
+ "total_artifacts": len(artifacts),
131
+ "present": sum(1 for v in artifacts.values() if v == "present"),
132
+ "missing": missing,
133
+ "generated_at": datetime.now(timezone.utc).isoformat(),
134
+ }
135
+ return [{"type": "text", "text": json.dumps(result, indent=2)}]
136
+
137
+
138
+ def _build_summary_md(
139
+ brief: dict,
140
+ validation: dict | None,
141
+ source_evidence: list | None,
142
+ claim_map: dict | None,
143
+ output_files: list[str],
144
+ artifacts: dict[str, str],
145
+ reviewer_notes: str | None,
146
+ ) -> str:
147
+ lines: list[str] = []
148
+ lines.append(f"# MLR Review Summary — {brief.get('campaign_name', 'Campaign')}")
149
+ lines.append("")
150
+ lines.append(f"**Campaign ID:** {brief.get('campaign_brief_id', '')}")
151
+ lines.append(f"**Brand:** {brief.get('brand', '')}")
152
+ lines.append(f"**Indication:** {brief.get('indication', '')}")
153
+ lines.append(f"**Mode:** {brief.get('mode', '')}")
154
+ lines.append(f"**Jurisdiction:** {brief.get('policy_jurisdiction', '')}")
155
+ lines.append(f"**Target Segment:** {brief.get('target_segment', '')}")
156
+ lines.append(f"**Objective:** {brief.get('behavioral_objective', '')}")
157
+ lines.append(f"**Channels:** {', '.join(brief.get('channels', []))}")
158
+ lines.append(f"**Approval Workflow:** {brief.get('approval_workflow', '')}")
159
+ lines.append("")
160
+
161
+ # Claim-to-source traceability
162
+ lines.append("## Claim-to-Source Traceability")
163
+ lines.append("")
164
+ if claim_map:
165
+ lines.append("| Copy Statement | Claim IDs |")
166
+ lines.append("|---|---|")
167
+ for stmt, cids in claim_map.items():
168
+ lines.append(f"| {stmt} | {', '.join(cids)} |")
169
+ else:
170
+ lines.append("_No claim map available._")
171
+ lines.append("")
172
+
173
+ # Source evidence
174
+ lines.append("## Source Evidence")
175
+ lines.append("")
176
+ if source_evidence and isinstance(source_evidence, list):
177
+ lines.append("| Document | Reference |")
178
+ lines.append("|---|---|")
179
+ for src in source_evidence:
180
+ lines.append(f"| {src.get('document_name', '')} | {src.get('excerpt', '')} |")
181
+ else:
182
+ lines.append("_No source evidence available._")
183
+ lines.append("")
184
+
185
+ # Validation summary
186
+ lines.append("## Validation Summary")
187
+ lines.append("")
188
+ if validation:
189
+ overall = "PASS" if validation.get("overall_pass") else "FAIL"
190
+ lines.append(f"**Overall:** {overall}")
191
+ lines.append("")
192
+ checks = validation.get("policy_checks", [])
193
+ if checks:
194
+ lines.append("| Check | Result | Detail |")
195
+ lines.append("|---|---|---|")
196
+ for c in checks:
197
+ lines.append(f"| {c.get('check_name', '')} | {c.get('result', '')} | {c.get('detail', '')} |")
198
+ lines.append("")
199
+ else:
200
+ lines.append("_No validation report available._")
201
+ lines.append("")
202
+
203
+ # Assets
204
+ lines.append("## Rendered Assets")
205
+ lines.append("")
206
+ if output_files:
207
+ for f in output_files:
208
+ lines.append(f"- `{f}`")
209
+ else:
210
+ lines.append("_No rendered assets._")
211
+ lines.append("")
212
+
213
+ # Package completeness
214
+ lines.append("## Package Completeness")
215
+ lines.append("")
216
+ lines.append("| Artifact | Status |")
217
+ lines.append("|---|---|")
218
+ for name, status in sorted(artifacts.items()):
219
+ icon = "present" if status == "present" else "**MISSING**"
220
+ lines.append(f"| {name} | {icon} |")
221
+ lines.append("")
222
+
223
+ # Reviewer notes
224
+ if reviewer_notes:
225
+ lines.append("## Reviewer Notes")
226
+ lines.append("")
227
+ lines.append(reviewer_notes)
228
+ lines.append("")
229
+
230
+ return "\n".join(lines)