open-pharma-plugins 2.2.0__tar.gz → 2.4.0__tar.gz

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 (180) hide show
  1. open_pharma_plugins-2.4.0/NOTICE +1 -0
  2. {open_pharma_plugins-2.2.0/src/open_pharma_plugins.egg-info → open_pharma_plugins-2.4.0}/PKG-INFO +10 -3
  3. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/README.md +6 -2
  4. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/pyproject.toml +4 -2
  5. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/__init__.py +1 -1
  6. open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/_campaign_store.py +439 -0
  7. open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/_claim_engine.py +821 -0
  8. open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/_claims.py +62 -0
  9. open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/_inputs.py +1143 -0
  10. open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/_mlr_package.py +1286 -0
  11. open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/_render_validation.py +1970 -0
  12. open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/_renderer.py +1030 -0
  13. open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/_workflow_validation.py +1104 -0
  14. open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/fixtures/brand_kit/product.png +0 -0
  15. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/fixtures/sample_approved_claims.json +26 -13
  16. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/models/brief.py +43 -3
  17. open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/models/claims.py +66 -0
  18. open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/models/copy.py +101 -0
  19. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/models/message.py +11 -2
  20. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/models/validation.py +14 -0
  21. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/policy/rules.json +18 -0
  22. open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/templates/banner.svg.j2 +17 -0
  23. open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/templates/email.html.j2 +61 -0
  24. open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/templates/mlr-review.html.j2 +252 -0
  25. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/create_campaign_brief.py +56 -24
  26. open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/export_mlr_package.py +52 -0
  27. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/generate_audience_journey.py +10 -50
  28. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/generate_channel_copy.py +14 -71
  29. open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/generate_message_architecture.py +180 -0
  30. open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/get_campaign_status.py +664 -0
  31. open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/package_mlr_submission.py +44 -0
  32. open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/preflight_campaign_inputs.py +41 -0
  33. open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/render_banner.py +211 -0
  34. open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/render_email.py +145 -0
  35. open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/render_mlr_review.py +44 -0
  36. open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/render_poster.py +202 -0
  37. open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/retrieve_approved_claims.py +51 -0
  38. open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/retrieve_brand_components.py +43 -0
  39. open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/validate_claims_and_fair_balance.py +429 -0
  40. open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/validate_rendered_assets.py +31 -0
  41. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/tools/ci_status.py +1 -1
  42. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/next-best-engagement/open_pharma_plugins_next_best_engagement/__init__.py +3 -3
  43. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/next-best-engagement/open_pharma_plugins_next_best_engagement/_optimizer.py +95 -74
  44. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/next-best-engagement/open_pharma_plugins_next_best_engagement/_renderer.py +35 -25
  45. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/next-best-engagement/open_pharma_plugins_next_best_engagement/_scoring.py +4 -4
  46. open_pharma_plugins-2.4.0/src/capabilities/next-best-engagement/open_pharma_plugins_next_best_engagement/_universe.py +206 -0
  47. open_pharma_plugins-2.4.0/src/capabilities/next-best-engagement/open_pharma_plugins_next_best_engagement/models.py +215 -0
  48. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/next-best-engagement/open_pharma_plugins_next_best_engagement/tools/load_universe.py +9 -6
  49. open_pharma_plugins-2.4.0/src/capabilities/next-best-engagement/open_pharma_plugins_next_best_engagement/tools/recommend_engagements.py +82 -0
  50. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/next-best-engagement/open_pharma_plugins_next_best_engagement/tools/render_plan.py +39 -11
  51. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/__init__.py +1 -1
  52. open_pharma_plugins-2.4.0/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/data.py +465 -0
  53. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/fixtures/constraints.csv +0 -2
  54. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/geo.py +11 -7
  55. open_pharma_plugins-2.4.0/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/models.py +268 -0
  56. open_pharma_plugins-2.4.0/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/reporting.py +768 -0
  57. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/scoring.py +13 -9
  58. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/solver.py +212 -37
  59. open_pharma_plugins-2.4.0/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/tools/ta_align.py +210 -0
  60. open_pharma_plugins-2.4.0/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/tools/ta_cluster.py +395 -0
  61. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/tools/ta_compare.py +29 -5
  62. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/tools/ta_evaluate.py +14 -7
  63. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/tools/ta_status.py +4 -1
  64. open_pharma_plugins-2.4.0/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/tools/ta_visualize.py +143 -0
  65. open_pharma_plugins-2.4.0/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/workload.py +30 -0
  66. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/mcp_framework.py +1 -1
  67. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0/src/open_pharma_plugins.egg-info}/PKG-INFO +10 -3
  68. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/open_pharma_plugins.egg-info/SOURCES.txt +14 -0
  69. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/open_pharma_plugins.egg-info/requires.txt +2 -0
  70. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/shared/env.py +1 -1
  71. open_pharma_plugins-2.4.0/src/shared/filesystem.py +1308 -0
  72. open_pharma_plugins-2.2.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/_campaign_store.py +0 -162
  73. open_pharma_plugins-2.2.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/_claim_engine.py +0 -262
  74. open_pharma_plugins-2.2.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/_renderer.py +0 -119
  75. open_pharma_plugins-2.2.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/fixtures/brand_kit/product.png +0 -1
  76. open_pharma_plugins-2.2.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/models/claims.py +0 -15
  77. open_pharma_plugins-2.2.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/models/copy.py +0 -47
  78. open_pharma_plugins-2.2.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/templates/banner.svg.j2 +0 -26
  79. open_pharma_plugins-2.2.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/templates/email.html.j2 +0 -54
  80. open_pharma_plugins-2.2.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/generate_message_architecture.py +0 -121
  81. open_pharma_plugins-2.2.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/package_mlr_submission.py +0 -230
  82. open_pharma_plugins-2.2.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/render_banner.py +0 -99
  83. open_pharma_plugins-2.2.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/render_email.py +0 -101
  84. open_pharma_plugins-2.2.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/render_poster.py +0 -222
  85. open_pharma_plugins-2.2.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/retrieve_approved_claims.py +0 -71
  86. open_pharma_plugins-2.2.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/retrieve_brand_components.py +0 -76
  87. open_pharma_plugins-2.2.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/validate_claims_and_fair_balance.py +0 -285
  88. open_pharma_plugins-2.2.0/src/capabilities/next-best-engagement/open_pharma_plugins_next_best_engagement/_universe.py +0 -145
  89. open_pharma_plugins-2.2.0/src/capabilities/next-best-engagement/open_pharma_plugins_next_best_engagement/models.py +0 -135
  90. open_pharma_plugins-2.2.0/src/capabilities/next-best-engagement/open_pharma_plugins_next_best_engagement/tools/recommend_engagements.py +0 -80
  91. open_pharma_plugins-2.2.0/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/data.py +0 -300
  92. open_pharma_plugins-2.2.0/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/models.py +0 -201
  93. open_pharma_plugins-2.2.0/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/tools/ta_align.py +0 -138
  94. open_pharma_plugins-2.2.0/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/tools/ta_cluster.py +0 -247
  95. open_pharma_plugins-2.2.0/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/tools/ta_visualize.py +0 -504
  96. open_pharma_plugins-2.2.0/src/shared/filesystem.py +0 -110
  97. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/LICENSE +0 -0
  98. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/setup.cfg +0 -0
  99. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/__main__.py +0 -0
  100. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/fixtures/brand_kit/legal.json +0 -0
  101. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/fixtures/brand_kit/logo.svg +0 -0
  102. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/fixtures/brand_kit/palette.json +0 -0
  103. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/fixtures/brand_kit/typography.json +0 -0
  104. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/models/__init__.py +0 -0
  105. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/models/_common.py +0 -0
  106. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/models/journey.py +0 -0
  107. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/models/mlr.py +0 -0
  108. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/__init__.py +0 -0
  109. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/__init__.py +0 -0
  110. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/__main__.py +0 -0
  111. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/_artifacts.py +0 -0
  112. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/_cache.py +0 -0
  113. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/_clinical_trials.py +0 -0
  114. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/_dailymed.py +0 -0
  115. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/_fda.py +0 -0
  116. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/_pubmed.py +0 -0
  117. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/_regulatory.py +0 -0
  118. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/_runs.py +0 -0
  119. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/_transport.py +0 -0
  120. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/_watchlist.py +0 -0
  121. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/_web_search.py +0 -0
  122. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/models.py +0 -0
  123. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/tools/__init__.py +0 -0
  124. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/tools/ci_extract_events.py +0 -0
  125. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/tools/ci_landscape.py +0 -0
  126. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/tools/ci_refresh.py +0 -0
  127. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/tools/ci_report.py +0 -0
  128. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/tools/ci_scan_news.py +0 -0
  129. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/tools/ci_scan_publications.py +0 -0
  130. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/tools/ci_scan_regulatory.py +0 -0
  131. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/tools/ci_scan_trials.py +0 -0
  132. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/tools/ci_timeline.py +0 -0
  133. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/tools/ci_track.py +0 -0
  134. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/tools/ci_trial_detail.py +0 -0
  135. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/field-training/open_pharma_plugins_field_training/__init__.py +0 -0
  136. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/field-training/open_pharma_plugins_field_training/__main__.py +0 -0
  137. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/field-training/open_pharma_plugins_field_training/_content_store.py +0 -0
  138. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/field-training/open_pharma_plugins_field_training/_grounding.py +0 -0
  139. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/field-training/open_pharma_plugins_field_training/_html_renderers.py +0 -0
  140. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/field-training/open_pharma_plugins_field_training/fixtures/sample_product_message.pdf +0 -0
  141. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/field-training/open_pharma_plugins_field_training/fixtures/sample_training_deck.pptx +0 -0
  142. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/field-training/open_pharma_plugins_field_training/models.py +0 -0
  143. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/field-training/open_pharma_plugins_field_training/tools/__init__.py +0 -0
  144. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/field-training/open_pharma_plugins_field_training/tools/get_document_page.py +0 -0
  145. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/field-training/open_pharma_plugins_field_training/tools/ingest_document.py +0 -0
  146. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/field-training/open_pharma_plugins_field_training/tools/list_documents.py +0 -0
  147. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/field-training/open_pharma_plugins_field_training/tools/render_output.py +0 -0
  148. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/field-training/open_pharma_plugins_field_training/tools/search_content.py +0 -0
  149. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/__init__.py +0 -0
  150. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/__main__.py +0 -0
  151. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/_crm_store.py +0 -0
  152. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/batch.py +0 -0
  153. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/batch_cli.py +0 -0
  154. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/batch_csv.py +0 -0
  155. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/fixtures/sample_accounts.csv +0 -0
  156. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/models.py +0 -0
  157. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/tools/__init__.py +0 -0
  158. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/tools/get_account.py +0 -0
  159. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/tools/list_accounts.py +0 -0
  160. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/tools/search_clinical_trials.py +0 -0
  161. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/tools/search_congresses.py +0 -0
  162. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/tools/search_grants.py +0 -0
  163. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/tools/search_guidelines.py +0 -0
  164. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/tools/search_hco_web.py +0 -0
  165. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/tools/search_hcp_web.py +0 -0
  166. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/tools/search_orcid.py +0 -0
  167. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/tools/search_publications.py +0 -0
  168. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/tools/update_account.py +0 -0
  169. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/next-best-engagement/open_pharma_plugins_next_best_engagement/__main__.py +0 -0
  170. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/next-best-engagement/open_pharma_plugins_next_best_engagement/fixtures/sample_universe.csv +0 -0
  171. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/next-best-engagement/open_pharma_plugins_next_best_engagement/tools/__init__.py +0 -0
  172. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/__main__.py +0 -0
  173. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/fixtures/current_alignment.csv +0 -0
  174. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/fixtures/hcps.csv +0 -0
  175. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/fixtures/reps.csv +0 -0
  176. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/tools/__init__.py +0 -0
  177. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/open_pharma_plugins.egg-info/dependency_links.txt +0 -0
  178. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/open_pharma_plugins.egg-info/entry_points.txt +0 -0
  179. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/open_pharma_plugins.egg-info/top_level.txt +0 -0
  180. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/shared/__init__.py +0 -0
@@ -0,0 +1 @@
1
+ Copyright 2026 Jason Zhang
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: open-pharma-plugins
3
- Version: 2.2.0
3
+ Version: 2.4.0
4
4
  Summary: Open Pharma Plugins: Agent Skills + MCP tools for pharmaceutical commercial operations — HCP intelligence, territory alignment, competitive intelligence, field training, engagement planning, and campaign creation.
5
5
  Author: EAI Commercial Team
6
6
  License-Expression: Apache-2.0
@@ -20,6 +20,7 @@ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
20
  Requires-Python: <3.14,>=3.10
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
+ License-File: NOTICE
23
24
  Requires-Dist: mcp<2,>=1.0.0
24
25
  Requires-Dist: anyio<5,>=4.0
25
26
  Requires-Dist: pydantic<3,>=2.8
@@ -42,6 +43,7 @@ Requires-Dist: pypdfium2<6,>=4; extra == "competitive-intelligence"
42
43
  Requires-Dist: python-docx<2,>=1; extra == "competitive-intelligence"
43
44
  Provides-Extra: campaign-studio
44
45
  Requires-Dist: jinja2<4,>=3.1; extra == "campaign-studio"
46
+ Requires-Dist: pypdf<7,>=5; extra == "campaign-studio"
45
47
  Requires-Dist: reportlab<6,>=4; extra == "campaign-studio"
46
48
  Provides-Extra: all
47
49
  Requires-Dist: requests<3,>=2.31; extra == "all"
@@ -51,6 +53,7 @@ Requires-Dist: python-pptx<2,>=1; extra == "all"
51
53
  Requires-Dist: python-docx<2,>=1; extra == "all"
52
54
  Requires-Dist: reportlab<6,>=4; extra == "all"
53
55
  Requires-Dist: jinja2<4,>=3.1; extra == "all"
56
+ Requires-Dist: pypdf<7,>=5; extra == "all"
54
57
  Dynamic: license-file
55
58
 
56
59
  # Open Pharma Plugins
@@ -59,13 +62,15 @@ Agent Skills and MCP tools for pharmaceutical commercial operations.
59
62
 
60
63
  > **Public beta.** Validate outputs before operational use. Campaign and field-training artifacts are drafts for qualified medical/legal/regulatory review, not automated approval.
61
64
 
65
+ The public business site at [pharmagenai.github.io](https://pharmagenai.github.io/) is the external product and company overview. Technical installation, capability, testing, and release documentation for operators and maintainers lives in this repository under [`docs/`](docs/) and the capability cookbooks, alongside the source code.
66
+
62
67
  ## Capabilities
63
68
 
64
69
  | Capability | Tools | Description |
65
70
  |---|---:|---|
66
71
  | [HCP Intelligence](cookbooks/hcp-intelligence/usage.md) | 11 | Build evidence-backed HCP/HCO profiles from public sources |
67
72
  | [Field Training](cookbooks/field-training/usage.md) | 5 | Turn approved PDF/PPTX paths into grounded learning packages, assessments, role-play kits, and scorecards |
68
- | [Campaign Studio](cookbooks/campaign-studio/usage.md) | 11 | Create, validate, render, and package campaign drafts for MLR review |
73
+ | [Campaign Studio](cookbooks/campaign-studio/usage.md) | 16 | Create, validate, render, and export campaign drafts for MLR review |
69
74
  | [Next-Best-Engagement](cookbooks/next-best-engagement/usage.md) | 3 | Score HCPs and produce consent-aware engagement plans |
70
75
  | [Territory Alignment](cookbooks/territory-alignment/usage.md) | 6 | Compare HCP-to-rep assignments and plan visit clusters |
71
76
  | [Competitive Intelligence](cookbooks/competitive-intelligence/usage.md) | 12 | Collect one evidence run and project reproducible briefings and timelines |
@@ -93,7 +98,7 @@ less install.sh
93
98
  bash install.sh
94
99
  ```
95
100
 
96
- The installer requires `uv`/`uvx`; it never installs a package manager on your behalf. See [Installation](docs/en/installation.md) for tag-pinned and source-checkout options.
101
+ The installer supports Claude Code, Codex, and GitHub Copilot CLI. It requires `uv`/`uvx` and never installs a package manager on your behalf. See [Installation](docs/en/installation.md) for tag-pinned and source-checkout options.
97
102
 
98
103
  ## Configuration and local data
99
104
 
@@ -119,6 +124,8 @@ tag-pinned `open-pharma-plugins-hcp-batch` console and produces canonical accoun
119
124
 
120
125
  ## Documentation
121
126
 
127
+ - [Public business site](https://pharmagenai.github.io/) for external product and company overview
128
+ - [Technical documentation in this repository](docs/) for installation, capability, testing, and release instructions
122
129
  - [Installation](docs/en/installation.md)
123
130
  - [Configuration](docs/en/configuration.md)
124
131
  - [HCP batch processing and CSV review](docs/en/hcp_batch.md)
@@ -4,13 +4,15 @@ Agent Skills and MCP tools for pharmaceutical commercial operations.
4
4
 
5
5
  > **Public beta.** Validate outputs before operational use. Campaign and field-training artifacts are drafts for qualified medical/legal/regulatory review, not automated approval.
6
6
 
7
+ The public business site at [pharmagenai.github.io](https://pharmagenai.github.io/) is the external product and company overview. Technical installation, capability, testing, and release documentation for operators and maintainers lives in this repository under [`docs/`](docs/) and the capability cookbooks, alongside the source code.
8
+
7
9
  ## Capabilities
8
10
 
9
11
  | Capability | Tools | Description |
10
12
  |---|---:|---|
11
13
  | [HCP Intelligence](cookbooks/hcp-intelligence/usage.md) | 11 | Build evidence-backed HCP/HCO profiles from public sources |
12
14
  | [Field Training](cookbooks/field-training/usage.md) | 5 | Turn approved PDF/PPTX paths into grounded learning packages, assessments, role-play kits, and scorecards |
13
- | [Campaign Studio](cookbooks/campaign-studio/usage.md) | 11 | Create, validate, render, and package campaign drafts for MLR review |
15
+ | [Campaign Studio](cookbooks/campaign-studio/usage.md) | 16 | Create, validate, render, and export campaign drafts for MLR review |
14
16
  | [Next-Best-Engagement](cookbooks/next-best-engagement/usage.md) | 3 | Score HCPs and produce consent-aware engagement plans |
15
17
  | [Territory Alignment](cookbooks/territory-alignment/usage.md) | 6 | Compare HCP-to-rep assignments and plan visit clusters |
16
18
  | [Competitive Intelligence](cookbooks/competitive-intelligence/usage.md) | 12 | Collect one evidence run and project reproducible briefings and timelines |
@@ -38,7 +40,7 @@ less install.sh
38
40
  bash install.sh
39
41
  ```
40
42
 
41
- The installer requires `uv`/`uvx`; it never installs a package manager on your behalf. See [Installation](docs/en/installation.md) for tag-pinned and source-checkout options.
43
+ The installer supports Claude Code, Codex, and GitHub Copilot CLI. It requires `uv`/`uvx` and never installs a package manager on your behalf. See [Installation](docs/en/installation.md) for tag-pinned and source-checkout options.
42
44
 
43
45
  ## Configuration and local data
44
46
 
@@ -64,6 +66,8 @@ tag-pinned `open-pharma-plugins-hcp-batch` console and produces canonical accoun
64
66
 
65
67
  ## Documentation
66
68
 
69
+ - [Public business site](https://pharmagenai.github.io/) for external product and company overview
70
+ - [Technical documentation in this repository](docs/) for installation, capability, testing, and release instructions
67
71
  - [Installation](docs/en/installation.md)
68
72
  - [Configuration](docs/en/configuration.md)
69
73
  - [HCP batch processing and CSV review](docs/en/hcp_batch.md)
@@ -9,7 +9,7 @@ description = "Open Pharma Plugins: Agent Skills + MCP tools for pharmaceutical
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10,<3.14"
11
11
  license = "Apache-2.0"
12
- license-files = ["LICENSE"]
12
+ license-files = ["LICENSE", "NOTICE"]
13
13
  authors = [{ name = "EAI Commercial Team" }]
14
14
  keywords = ["mcp", "pharma", "commercial", "agent-skills", "hcp"]
15
15
  classifiers = [
@@ -42,7 +42,7 @@ field-training-fixtures = ["pypdfium2>=4,<6", "python-pptx>=1,<2", "reportlab>=4
42
42
  next-best-engagement = []
43
43
  territory-alignment = []
44
44
  competitive-intelligence = ["pypdfium2>=4,<6", "python-docx>=1,<2"]
45
- campaign-studio = ["jinja2>=3.1,<4", "reportlab>=4,<6"]
45
+ campaign-studio = ["jinja2>=3.1,<4", "pypdf>=5,<7", "reportlab>=4,<6"]
46
46
  all = [
47
47
  "requests>=2.31,<3",
48
48
  "openai>=1,<4",
@@ -51,12 +51,14 @@ all = [
51
51
  "python-docx>=1,<2",
52
52
  "reportlab>=4,<6",
53
53
  "jinja2>=3.1,<4",
54
+ "pypdf>=5,<7",
54
55
  ]
55
56
 
56
57
  [dependency-groups]
57
58
  dev = [
58
59
  "build>=1,<2",
59
60
  "cyclonedx-bom>=7.3,<8",
61
+ "markdown-it-py>=4,<5",
60
62
  "packaging>=24",
61
63
  "pip-audit>=2.9,<3",
62
64
  "pytest>=8,<10",
@@ -1,6 +1,6 @@
1
1
  from mcp_framework import build_registry
2
2
 
3
- __version__ = "1.0.1"
3
+ __version__ = "1.1.0"
4
4
 
5
5
  SPECS, get_handler, list_tools = build_registry(__name__, ["tools"])
6
6
 
@@ -0,0 +1,439 @@
1
+ """File-system store for campaign briefs and artifacts.
2
+
3
+ Layout:
4
+ <store_root>/
5
+ _index.json # manifest of all campaigns
6
+ campaigns/
7
+ <campaign_brief_id>/
8
+ campaign-brief.json
9
+ approved-claims.json
10
+ audience-journey.json
11
+ message-architecture.json
12
+ copy-email.json
13
+ copy-banner.json
14
+ copy-poster.json
15
+ validation/
16
+ claim-map.json
17
+ policy-checks.json
18
+ source-evidence.json
19
+ outputs/
20
+ email.html
21
+ banner.svg
22
+ poster.pdf
23
+ mlr-review-summary.md
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import hashlib
29
+ import json
30
+ import math
31
+ import stat
32
+ from datetime import datetime, timezone
33
+ from pathlib import Path
34
+
35
+ from shared.filesystem import (
36
+ atomic_write_bytes,
37
+ atomic_write_json,
38
+ atomic_write_text,
39
+ contained_path,
40
+ ensure_private_dir,
41
+ validate_component,
42
+ )
43
+
44
+
45
+ def store_root_path() -> Path:
46
+ """Return the configured store root without creating it."""
47
+ from shared.env import get_env
48
+
49
+ return Path(
50
+ get_env(
51
+ "OPEN_PHARMA_CAMPAIGN_STORE_DIR",
52
+ str(Path.home() / ".open-pharma-plugins" / "campaign-studio"),
53
+ )
54
+ )
55
+
56
+
57
+ def _store_dir() -> Path:
58
+ """Return the private store root for write operations."""
59
+ return ensure_private_dir(store_root_path())
60
+
61
+
62
+ def _storage_error(code: str, message: str) -> dict[str, str]:
63
+ return {"code": code, "message": message}
64
+
65
+
66
+ def _configured_root_for_read() -> tuple[Path | None, dict[str, str] | None]:
67
+ """Resolve an existing configured root only after rejecting a symlink root."""
68
+ root = store_root_path().expanduser()
69
+ if not root.is_absolute():
70
+ root = (Path.cwd() / root).absolute()
71
+ try:
72
+ if root.is_symlink():
73
+ return None, _storage_error("unsafe_store_root", "Campaign store root may not be a symlink.")
74
+ if root.exists():
75
+ if not root.is_dir():
76
+ return None, _storage_error("unsafe_store_root", "Campaign store root must be a directory.")
77
+ return root.resolve(strict=True), None
78
+ except OSError:
79
+ return None, _storage_error("unsafe_store_root", "Campaign store root could not be inspected safely.")
80
+ return root, None
81
+
82
+
83
+ def _existing_safe_directory(path: Path, *, code: str, label: str) -> tuple[Path | None, dict[str, str] | None]:
84
+ """Return a real directory or a structured error; never follow a symlink."""
85
+ try:
86
+ if path.is_symlink():
87
+ return None, _storage_error(code, f"{label} may not be a symlink.")
88
+ if not path.exists():
89
+ return None, None
90
+ if not path.is_dir():
91
+ return None, _storage_error(code, f"{label} must be a directory.")
92
+ return path.resolve(strict=True), None
93
+ except OSError:
94
+ return None, _storage_error(code, f"{label} could not be inspected safely.")
95
+
96
+
97
+ def safe_campaign_path(campaign_brief_id: object) -> tuple[Path | None, dict[str, str] | None]:
98
+ """Resolve a non-creating campaign path, rejecting every symlinked store component."""
99
+ try:
100
+ validate_component(campaign_brief_id, label="campaign_brief_id")
101
+ except ValueError as exc:
102
+ return None, {"code": "unsafe_campaign_brief_id", "message": str(exc)}
103
+ root, root_error = _configured_root_for_read()
104
+ if root_error or root is None:
105
+ return None, root_error
106
+ campaigns, campaigns_error = _existing_safe_directory(
107
+ root / "campaigns", code="unsafe_campaign_path", label="Campaigns directory"
108
+ )
109
+ if campaigns_error:
110
+ return None, campaigns_error
111
+ if campaigns is None:
112
+ return root / "campaigns" / str(campaign_brief_id), None
113
+ try:
114
+ campaigns.relative_to(root)
115
+ except ValueError:
116
+ return None, _storage_error("unsafe_campaign_path", "Campaigns directory escapes the configured store root.")
117
+ campaign, campaign_error = _existing_safe_directory(
118
+ campaigns / str(campaign_brief_id), code="unsafe_campaign_path", label="Campaign directory"
119
+ )
120
+ if campaign_error:
121
+ return None, campaign_error
122
+ if campaign is not None:
123
+ try:
124
+ campaign.relative_to(campaigns)
125
+ except ValueError:
126
+ return None, _storage_error("unsafe_campaign_path", "Campaign directory escapes the campaigns root.")
127
+ return campaign or campaigns / str(campaign_brief_id), None
128
+
129
+
130
+ def existing_campaign_path_result(campaign_brief_id: object) -> tuple[Path | None, dict[str, str] | None]:
131
+ """Return an existing real campaign directory or its structured safety error."""
132
+ path, error = safe_campaign_path(campaign_brief_id)
133
+ if error or path is None:
134
+ return None, error
135
+ return _existing_safe_directory(path, code="unsafe_campaign_path", label="Campaign directory")
136
+
137
+
138
+ def existing_campaign_path(campaign_brief_id: object) -> Path | None:
139
+ """Return an existing campaign directory without creating any path."""
140
+ path, _error = existing_campaign_path_result(campaign_brief_id)
141
+ return path
142
+
143
+
144
+ def existing_directory_path(campaign_brief_id: object, section: str) -> tuple[Path | None, dict[str, str] | None]:
145
+ """Return an existing real campaign subdirectory without following a symlink."""
146
+ try:
147
+ validate_component(section, label="campaign section")
148
+ except ValueError as exc:
149
+ return None, _storage_error("unsafe_artifact_directory", str(exc))
150
+ campaign, campaign_error = existing_campaign_path_result(campaign_brief_id)
151
+ if campaign_error or campaign is None:
152
+ return None, campaign_error
153
+ code = "unsafe_outputs_directory" if section == "outputs" else "unsafe_artifact_directory"
154
+ directory, directory_error = _existing_safe_directory(
155
+ campaign / section, code=code, label=f"Campaign {section} directory"
156
+ )
157
+ if directory_error or directory is None:
158
+ return directory, directory_error
159
+ try:
160
+ directory.relative_to(campaign)
161
+ except ValueError:
162
+ return None, _storage_error(code, f"Campaign {section} directory escapes the campaign path.")
163
+ return directory, None
164
+
165
+
166
+ def existing_artifact_path_result(
167
+ campaign_brief_id: object, filename: str, *, section: str | None = None
168
+ ) -> tuple[Path | None, dict[str, str] | None]:
169
+ """Return a real contained regular artifact file or a structured safety error."""
170
+ try:
171
+ validate_component(filename, label="artifact filename")
172
+ except ValueError as exc:
173
+ return None, _storage_error("unsafe_artifact_path", str(exc))
174
+ if section is None:
175
+ directory, error = existing_campaign_path_result(campaign_brief_id)
176
+ else:
177
+ directory, error = existing_directory_path(campaign_brief_id, section)
178
+ if error or directory is None:
179
+ return None, error
180
+ candidate = directory / filename
181
+ try:
182
+ if candidate.is_symlink():
183
+ return None, _storage_error("unsafe_artifact_path", "Campaign artifact may not be a symlink.")
184
+ if not candidate.exists():
185
+ return None, None
186
+ if not candidate.is_file():
187
+ return None, _storage_error("unsafe_artifact_path", "Campaign artifact must be a regular file.")
188
+ resolved = candidate.resolve(strict=True)
189
+ resolved.relative_to(directory)
190
+ return resolved, None
191
+ except (OSError, ValueError):
192
+ return None, _storage_error("unsafe_artifact_path", "Campaign artifact path is unsafe or unreadable.")
193
+
194
+
195
+ def existing_artifact_path(campaign_brief_id: object, filename: str, *, section: str | None = None) -> Path | None:
196
+ """Return an existing contained artifact path without creating it."""
197
+ path, _error = existing_artifact_path_result(campaign_brief_id, filename, section=section)
198
+ return path
199
+
200
+
201
+ def read_existing_json(path: Path | None) -> tuple[object | None, str | None]:
202
+ """Read a JSON file for a read-only caller without leaking filesystem exceptions."""
203
+ if path is None:
204
+ return None, "missing"
205
+ try:
206
+ value = json.loads(
207
+ path.read_text(encoding="utf-8"),
208
+ parse_constant=_invalid_json_constant,
209
+ parse_float=_finite_json_float,
210
+ object_pairs_hook=_unique_json_object,
211
+ )
212
+ if not _json_within_safe_limits(value):
213
+ return None, "JSON exceeds safe nesting or node limits"
214
+ return value, None
215
+ except (
216
+ OSError,
217
+ ValueError,
218
+ json.JSONDecodeError,
219
+ UnicodeDecodeError,
220
+ RecursionError,
221
+ OverflowError,
222
+ MemoryError,
223
+ ) as exc:
224
+ return None, f"unreadable JSON: {exc}"
225
+
226
+
227
+ def _json_within_safe_limits(value: object) -> bool:
228
+ """Bound parser output before status/seal serialization can recurse or exhaust resources."""
229
+ maximum_depth = 256
230
+ maximum_nodes = 100_000
231
+ nodes = 0
232
+ pending: list[tuple[object, int]] = [(value, 1)]
233
+ while pending:
234
+ current, depth = pending.pop()
235
+ nodes += 1
236
+ if depth > maximum_depth or nodes > maximum_nodes:
237
+ return False
238
+ if isinstance(current, dict):
239
+ pending.extend((item, depth + 1) for item in current.values())
240
+ elif isinstance(current, list):
241
+ pending.extend((item, depth + 1) for item in current)
242
+ return True
243
+
244
+
245
+ def _invalid_json_constant(value: str) -> object:
246
+ """Reject non-standard numeric constants so seal JSON remains canonical."""
247
+ raise ValueError(f"non-finite JSON constant: {value}")
248
+
249
+
250
+ def _finite_json_float(value: str) -> float:
251
+ """Reject syntactically valid but non-finite numeric literals."""
252
+ number = float(value)
253
+ if not math.isfinite(number):
254
+ raise ValueError("non-finite JSON number")
255
+ return number
256
+
257
+
258
+ def _unique_json_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
259
+ """Reject duplicate object keys rather than silently selecting a mutable last value."""
260
+ result: dict[str, object] = {}
261
+ for key, value in pairs:
262
+ if key in result:
263
+ raise ValueError(f"duplicate JSON key: {key}")
264
+ result[key] = value
265
+ return result
266
+
267
+
268
+ def read_campaign_json(
269
+ campaign_brief_id: object, filename: str, *, section: str | None = None
270
+ ) -> tuple[object | None, dict[str, str] | None, Path | None]:
271
+ """Safely read a persisted JSON artifact for fail-closed status and seal consumers."""
272
+ path, path_error = existing_artifact_path_result(campaign_brief_id, filename, section=section)
273
+ if path_error:
274
+ return None, path_error, None
275
+ if path is None:
276
+ return None, _storage_error("artifact_missing", f"Campaign artifact is missing: {filename}"), None
277
+ data, read_error = read_existing_json(path)
278
+ if read_error:
279
+ return (
280
+ None,
281
+ _storage_error("artifact_json_unreadable", f"Campaign artifact JSON is unreadable: {filename}"),
282
+ path,
283
+ )
284
+ return data, None, path
285
+
286
+
287
+ def existing_output_paths(campaign_brief_id: object) -> list[Path]:
288
+ """List regular files in an existing outputs directory, sorted and non-creating."""
289
+ paths, _error = existing_output_paths_result(campaign_brief_id)
290
+ return paths
291
+
292
+
293
+ def existing_output_paths_result(campaign_brief_id: object) -> tuple[list[Path], dict[str, object] | None]:
294
+ """List safe regular output files and report unsafe siblings without hiding the safe ones."""
295
+ outputs, error = existing_directory_path(campaign_brief_id, "outputs")
296
+ if error or outputs is None:
297
+ return [], error
298
+ try:
299
+ result: list[Path] = []
300
+ entry_errors: list[dict[str, str]] = []
301
+ for entry in sorted(outputs.iterdir(), key=lambda path: path.name):
302
+ try:
303
+ entry_stat = entry.lstat()
304
+ except OSError:
305
+ entry_errors.append(
306
+ _storage_error("unsafe_output_entry", f"Campaign outputs contain an unsafe entry: {entry.name}.")
307
+ )
308
+ continue
309
+ if stat.S_ISLNK(entry_stat.st_mode) or not stat.S_ISREG(entry_stat.st_mode):
310
+ entry_errors.append(
311
+ _storage_error("unsafe_output_entry", f"Campaign outputs contain an unsafe entry: {entry.name}.")
312
+ )
313
+ continue
314
+ resolved = entry.resolve(strict=True)
315
+ resolved.relative_to(outputs)
316
+ result.append(resolved)
317
+ if entry_errors:
318
+ return (
319
+ result,
320
+ {
321
+ "code": "unsafe_outputs_directory",
322
+ "message": "Campaign outputs contain unsafe entries.",
323
+ "entries": entry_errors,
324
+ },
325
+ )
326
+ return result, None
327
+ except (OSError, ValueError):
328
+ return [], _storage_error("unsafe_outputs_directory", "Campaign outputs could not be inspected safely.")
329
+
330
+
331
+ def campaign_dir(campaign_brief_id: str) -> Path:
332
+ """Return (and create) the directory for a specific campaign."""
333
+ return ensure_private_dir(contained_path(_store_dir(), "campaigns", campaign_brief_id))
334
+
335
+
336
+ def validation_dir(campaign_brief_id: str) -> Path:
337
+ """Return (and create) the validation subdirectory."""
338
+ return ensure_private_dir(contained_path(campaign_dir(campaign_brief_id), "validation"))
339
+
340
+
341
+ def outputs_dir(campaign_brief_id: str) -> Path:
342
+ """Return (and create) the outputs subdirectory."""
343
+ return ensure_private_dir(contained_path(campaign_dir(campaign_brief_id), "outputs"))
344
+
345
+
346
+ def generate_campaign_id(campaign_name: str, brand: str) -> str:
347
+ """Generate a stable campaign ID from name + brand."""
348
+ raw = f"{campaign_name}:{brand}"
349
+ short_hash = hashlib.sha256(raw.encode()).hexdigest()[:8]
350
+ safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in campaign_name.lower())[:30]
351
+ return f"{safe_name}_{short_hash}"
352
+
353
+
354
+ def save_artifact(campaign_brief_id: str, filename: str, data: dict) -> Path:
355
+ """Save a JSON artifact to the campaign directory."""
356
+ path = contained_path(campaign_dir(campaign_brief_id), filename)
357
+ atomic_write_json(path, data)
358
+ return path
359
+
360
+
361
+ def load_artifact(campaign_brief_id: str, filename: str) -> dict | None:
362
+ """Load a JSON artifact without creating storage; corrupt persisted JSON remains an error."""
363
+ path = existing_artifact_path(campaign_brief_id, filename)
364
+ if path is not None:
365
+ return json.loads(path.read_text(encoding="utf-8"))
366
+ return None
367
+
368
+
369
+ def save_validation_artifact(campaign_brief_id: str, filename: str, data: dict) -> Path:
370
+ """Save a validation artifact."""
371
+ path = contained_path(validation_dir(campaign_brief_id), filename)
372
+ atomic_write_json(path, data)
373
+ return path
374
+
375
+
376
+ def load_validation_artifact(campaign_brief_id: str, filename: str) -> dict | None:
377
+ """Load a validation artifact without creating storage; corrupt persisted JSON remains an error."""
378
+ path = existing_artifact_path(campaign_brief_id, filename, section="validation")
379
+ if path is not None:
380
+ return json.loads(path.read_text(encoding="utf-8"))
381
+ return None
382
+
383
+
384
+ def save_output(campaign_brief_id: str, filename: str, content: str) -> Path:
385
+ """Save a rendered output (HTML, SVG, PDF bytes, MD)."""
386
+ path = contained_path(outputs_dir(campaign_brief_id), filename)
387
+ atomic_write_text(path, content)
388
+ return path
389
+
390
+
391
+ def save_output_bytes(campaign_brief_id: str, filename: str, content: bytes) -> Path:
392
+ """Save binary output (e.g. PDF)."""
393
+ path = contained_path(outputs_dir(campaign_brief_id), filename)
394
+ atomic_write_bytes(path, content)
395
+ return path
396
+
397
+
398
+ def list_campaigns() -> list[dict]:
399
+ """List all campaigns from the index."""
400
+ index = _load_index()
401
+ return index.get("campaigns", [])
402
+
403
+
404
+ def save_brief(brief: dict) -> None:
405
+ """Save a campaign brief and update the index."""
406
+ campaign_brief_id = brief["campaign_brief_id"]
407
+ save_artifact(campaign_brief_id, "campaign-brief.json", brief)
408
+ _update_index(brief)
409
+
410
+
411
+ def load_brief(campaign_brief_id: str) -> dict | None:
412
+ """Load a campaign brief by ID."""
413
+ return load_artifact(campaign_brief_id, "campaign-brief.json")
414
+
415
+
416
+ def _load_index() -> dict:
417
+ index_path = _store_dir() / "_index.json"
418
+ if index_path.exists():
419
+ return json.loads(index_path.read_text(encoding="utf-8"))
420
+ return {"campaigns": []}
421
+
422
+
423
+ def _update_index(brief: dict) -> None:
424
+ index = _load_index()
425
+ campaigns = index.get("campaigns", [])
426
+ campaigns = [c for c in campaigns if c.get("campaign_brief_id") != brief["campaign_brief_id"]]
427
+ campaigns.append(
428
+ {
429
+ "campaign_brief_id": brief["campaign_brief_id"],
430
+ "campaign_name": brief.get("campaign_name", ""),
431
+ "brand": brief.get("brand", ""),
432
+ "mode": brief.get("mode", ""),
433
+ "channels": brief.get("channels", []),
434
+ "created_at": brief.get("generated_at", datetime.now(timezone.utc).isoformat()),
435
+ }
436
+ )
437
+ index["campaigns"] = campaigns
438
+ index_path = _store_dir() / "_index.json"
439
+ atomic_write_json(index_path, index)