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.
- open_pharma_plugins-2.4.0/NOTICE +1 -0
- {open_pharma_plugins-2.2.0/src/open_pharma_plugins.egg-info → open_pharma_plugins-2.4.0}/PKG-INFO +10 -3
- {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/README.md +6 -2
- {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/pyproject.toml +4 -2
- {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
- open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/_campaign_store.py +439 -0
- open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/_claim_engine.py +821 -0
- open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/_claims.py +62 -0
- open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/_inputs.py +1143 -0
- open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/_mlr_package.py +1286 -0
- open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/_render_validation.py +1970 -0
- open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/_renderer.py +1030 -0
- open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/_workflow_validation.py +1104 -0
- open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/fixtures/brand_kit/product.png +0 -0
- {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
- {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
- open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/models/claims.py +66 -0
- open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/models/copy.py +101 -0
- {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
- {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
- {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
- open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/templates/banner.svg.j2 +17 -0
- open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/templates/email.html.j2 +61 -0
- open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/templates/mlr-review.html.j2 +252 -0
- {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
- open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/export_mlr_package.py +52 -0
- {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
- {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
- open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/generate_message_architecture.py +180 -0
- open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/get_campaign_status.py +664 -0
- open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/package_mlr_submission.py +44 -0
- open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/preflight_campaign_inputs.py +41 -0
- open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/render_banner.py +211 -0
- open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/render_email.py +145 -0
- open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/render_mlr_review.py +44 -0
- open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/render_poster.py +202 -0
- open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/retrieve_approved_claims.py +51 -0
- open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/retrieve_brand_components.py +43 -0
- open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/validate_claims_and_fair_balance.py +429 -0
- open_pharma_plugins-2.4.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/validate_rendered_assets.py +31 -0
- {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
- {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
- {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
- {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
- {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
- open_pharma_plugins-2.4.0/src/capabilities/next-best-engagement/open_pharma_plugins_next_best_engagement/_universe.py +206 -0
- open_pharma_plugins-2.4.0/src/capabilities/next-best-engagement/open_pharma_plugins_next_best_engagement/models.py +215 -0
- {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
- open_pharma_plugins-2.4.0/src/capabilities/next-best-engagement/open_pharma_plugins_next_best_engagement/tools/recommend_engagements.py +82 -0
- {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
- {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
- open_pharma_plugins-2.4.0/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/data.py +465 -0
- {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
- {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
- open_pharma_plugins-2.4.0/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/models.py +268 -0
- open_pharma_plugins-2.4.0/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/reporting.py +768 -0
- {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
- {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
- open_pharma_plugins-2.4.0/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/tools/ta_align.py +210 -0
- open_pharma_plugins-2.4.0/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/tools/ta_cluster.py +395 -0
- {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
- {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
- {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
- open_pharma_plugins-2.4.0/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/tools/ta_visualize.py +143 -0
- open_pharma_plugins-2.4.0/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/workload.py +30 -0
- {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/mcp_framework.py +1 -1
- {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0/src/open_pharma_plugins.egg-info}/PKG-INFO +10 -3
- {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/open_pharma_plugins.egg-info/SOURCES.txt +14 -0
- {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/open_pharma_plugins.egg-info/requires.txt +2 -0
- {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/shared/env.py +1 -1
- open_pharma_plugins-2.4.0/src/shared/filesystem.py +1308 -0
- open_pharma_plugins-2.2.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/_campaign_store.py +0 -162
- open_pharma_plugins-2.2.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/_claim_engine.py +0 -262
- open_pharma_plugins-2.2.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/_renderer.py +0 -119
- open_pharma_plugins-2.2.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/fixtures/brand_kit/product.png +0 -1
- open_pharma_plugins-2.2.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/models/claims.py +0 -15
- open_pharma_plugins-2.2.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/models/copy.py +0 -47
- open_pharma_plugins-2.2.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/templates/banner.svg.j2 +0 -26
- open_pharma_plugins-2.2.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/templates/email.html.j2 +0 -54
- open_pharma_plugins-2.2.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/generate_message_architecture.py +0 -121
- open_pharma_plugins-2.2.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/package_mlr_submission.py +0 -230
- open_pharma_plugins-2.2.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/render_banner.py +0 -99
- open_pharma_plugins-2.2.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/render_email.py +0 -101
- open_pharma_plugins-2.2.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/render_poster.py +0 -222
- open_pharma_plugins-2.2.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/retrieve_approved_claims.py +0 -71
- open_pharma_plugins-2.2.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/retrieve_brand_components.py +0 -76
- open_pharma_plugins-2.2.0/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/validate_claims_and_fair_balance.py +0 -285
- open_pharma_plugins-2.2.0/src/capabilities/next-best-engagement/open_pharma_plugins_next_best_engagement/_universe.py +0 -145
- open_pharma_plugins-2.2.0/src/capabilities/next-best-engagement/open_pharma_plugins_next_best_engagement/models.py +0 -135
- open_pharma_plugins-2.2.0/src/capabilities/next-best-engagement/open_pharma_plugins_next_best_engagement/tools/recommend_engagements.py +0 -80
- open_pharma_plugins-2.2.0/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/data.py +0 -300
- open_pharma_plugins-2.2.0/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/models.py +0 -201
- open_pharma_plugins-2.2.0/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/tools/ta_align.py +0 -138
- open_pharma_plugins-2.2.0/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/tools/ta_cluster.py +0 -247
- open_pharma_plugins-2.2.0/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/tools/ta_visualize.py +0 -504
- open_pharma_plugins-2.2.0/src/shared/filesystem.py +0 -110
- {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/LICENSE +0 -0
- {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/setup.cfg +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/open_pharma_plugins.egg-info/dependency_links.txt +0 -0
- {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/open_pharma_plugins.egg-info/entry_points.txt +0 -0
- {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.4.0}/src/open_pharma_plugins.egg-info/top_level.txt +0 -0
- {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
|
{open_pharma_plugins-2.2.0/src/open_pharma_plugins.egg-info → open_pharma_plugins-2.4.0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: open-pharma-plugins
|
|
3
|
-
Version: 2.
|
|
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) |
|
|
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
|
|
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) |
|
|
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
|
|
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",
|
|
@@ -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)
|