open-pharma-plugins 2.2.0__tar.gz → 2.2.1__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 (144) hide show
  1. {open_pharma_plugins-2.2.0/src/open_pharma_plugins.egg-info → open_pharma_plugins-2.2.1}/PKG-INFO +1 -1
  2. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/tools/ci_status.py +1 -1
  3. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/next-best-engagement/open_pharma_plugins_next_best_engagement/__init__.py +3 -3
  4. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/next-best-engagement/open_pharma_plugins_next_best_engagement/_optimizer.py +95 -74
  5. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/next-best-engagement/open_pharma_plugins_next_best_engagement/_renderer.py +35 -25
  6. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/next-best-engagement/open_pharma_plugins_next_best_engagement/_scoring.py +4 -4
  7. open_pharma_plugins-2.2.1/src/capabilities/next-best-engagement/open_pharma_plugins_next_best_engagement/_universe.py +206 -0
  8. open_pharma_plugins-2.2.1/src/capabilities/next-best-engagement/open_pharma_plugins_next_best_engagement/models.py +215 -0
  9. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/next-best-engagement/open_pharma_plugins_next_best_engagement/tools/load_universe.py +9 -6
  10. open_pharma_plugins-2.2.1/src/capabilities/next-best-engagement/open_pharma_plugins_next_best_engagement/tools/recommend_engagements.py +82 -0
  11. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/next-best-engagement/open_pharma_plugins_next_best_engagement/tools/render_plan.py +39 -11
  12. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/mcp_framework.py +1 -1
  13. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1/src/open_pharma_plugins.egg-info}/PKG-INFO +1 -1
  14. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/shared/env.py +1 -1
  15. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/shared/filesystem.py +28 -0
  16. open_pharma_plugins-2.2.0/src/capabilities/next-best-engagement/open_pharma_plugins_next_best_engagement/_universe.py +0 -145
  17. open_pharma_plugins-2.2.0/src/capabilities/next-best-engagement/open_pharma_plugins_next_best_engagement/models.py +0 -135
  18. open_pharma_plugins-2.2.0/src/capabilities/next-best-engagement/open_pharma_plugins_next_best_engagement/tools/recommend_engagements.py +0 -80
  19. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/LICENSE +0 -0
  20. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/README.md +0 -0
  21. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/pyproject.toml +0 -0
  22. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/setup.cfg +0 -0
  23. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/__init__.py +0 -0
  24. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/__main__.py +0 -0
  25. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/_campaign_store.py +0 -0
  26. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/_claim_engine.py +0 -0
  27. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/_renderer.py +0 -0
  28. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/fixtures/brand_kit/legal.json +0 -0
  29. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/fixtures/brand_kit/logo.svg +0 -0
  30. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/fixtures/brand_kit/palette.json +0 -0
  31. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/fixtures/brand_kit/product.png +0 -0
  32. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/fixtures/brand_kit/typography.json +0 -0
  33. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/fixtures/sample_approved_claims.json +0 -0
  34. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/models/__init__.py +0 -0
  35. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/models/_common.py +0 -0
  36. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/models/brief.py +0 -0
  37. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/models/claims.py +0 -0
  38. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/models/copy.py +0 -0
  39. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/models/journey.py +0 -0
  40. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/models/message.py +0 -0
  41. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/models/mlr.py +0 -0
  42. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/models/validation.py +0 -0
  43. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/policy/rules.json +0 -0
  44. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/templates/banner.svg.j2 +0 -0
  45. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/templates/email.html.j2 +0 -0
  46. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/__init__.py +0 -0
  47. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/create_campaign_brief.py +0 -0
  48. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/generate_audience_journey.py +0 -0
  49. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/generate_channel_copy.py +0 -0
  50. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/generate_message_architecture.py +0 -0
  51. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/package_mlr_submission.py +0 -0
  52. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/render_banner.py +0 -0
  53. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/render_email.py +0 -0
  54. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/render_poster.py +0 -0
  55. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/retrieve_approved_claims.py +0 -0
  56. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/retrieve_brand_components.py +0 -0
  57. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/campaign-studio/open_pharma_plugins_campaign_studio/tools/validate_claims_and_fair_balance.py +0 -0
  58. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/__init__.py +0 -0
  59. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/__main__.py +0 -0
  60. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/_artifacts.py +0 -0
  61. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/_cache.py +0 -0
  62. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/_clinical_trials.py +0 -0
  63. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/_dailymed.py +0 -0
  64. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/_fda.py +0 -0
  65. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/_pubmed.py +0 -0
  66. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/_regulatory.py +0 -0
  67. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/_runs.py +0 -0
  68. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/_transport.py +0 -0
  69. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/_watchlist.py +0 -0
  70. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/_web_search.py +0 -0
  71. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/models.py +0 -0
  72. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/tools/__init__.py +0 -0
  73. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/tools/ci_extract_events.py +0 -0
  74. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/tools/ci_landscape.py +0 -0
  75. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/tools/ci_refresh.py +0 -0
  76. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/tools/ci_report.py +0 -0
  77. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/tools/ci_scan_news.py +0 -0
  78. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/tools/ci_scan_publications.py +0 -0
  79. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/tools/ci_scan_regulatory.py +0 -0
  80. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/tools/ci_scan_trials.py +0 -0
  81. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/tools/ci_timeline.py +0 -0
  82. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/tools/ci_track.py +0 -0
  83. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/competitive-intelligence/open_pharma_plugins_competitive_intelligence/tools/ci_trial_detail.py +0 -0
  84. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/field-training/open_pharma_plugins_field_training/__init__.py +0 -0
  85. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/field-training/open_pharma_plugins_field_training/__main__.py +0 -0
  86. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/field-training/open_pharma_plugins_field_training/_content_store.py +0 -0
  87. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/field-training/open_pharma_plugins_field_training/_grounding.py +0 -0
  88. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/field-training/open_pharma_plugins_field_training/_html_renderers.py +0 -0
  89. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/field-training/open_pharma_plugins_field_training/fixtures/sample_product_message.pdf +0 -0
  90. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/field-training/open_pharma_plugins_field_training/fixtures/sample_training_deck.pptx +0 -0
  91. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/field-training/open_pharma_plugins_field_training/models.py +0 -0
  92. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/field-training/open_pharma_plugins_field_training/tools/__init__.py +0 -0
  93. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/field-training/open_pharma_plugins_field_training/tools/get_document_page.py +0 -0
  94. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/field-training/open_pharma_plugins_field_training/tools/ingest_document.py +0 -0
  95. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/field-training/open_pharma_plugins_field_training/tools/list_documents.py +0 -0
  96. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/field-training/open_pharma_plugins_field_training/tools/render_output.py +0 -0
  97. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/field-training/open_pharma_plugins_field_training/tools/search_content.py +0 -0
  98. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/__init__.py +0 -0
  99. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/__main__.py +0 -0
  100. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/_crm_store.py +0 -0
  101. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/batch.py +0 -0
  102. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/batch_cli.py +0 -0
  103. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/batch_csv.py +0 -0
  104. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/fixtures/sample_accounts.csv +0 -0
  105. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/models.py +0 -0
  106. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/tools/__init__.py +0 -0
  107. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/tools/get_account.py +0 -0
  108. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/tools/list_accounts.py +0 -0
  109. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/tools/search_clinical_trials.py +0 -0
  110. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/tools/search_congresses.py +0 -0
  111. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/tools/search_grants.py +0 -0
  112. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/tools/search_guidelines.py +0 -0
  113. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/tools/search_hco_web.py +0 -0
  114. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/tools/search_hcp_web.py +0 -0
  115. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/tools/search_orcid.py +0 -0
  116. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/tools/search_publications.py +0 -0
  117. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/hcp-intelligence/open_pharma_plugins_hcp_intelligence/tools/update_account.py +0 -0
  118. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/next-best-engagement/open_pharma_plugins_next_best_engagement/__main__.py +0 -0
  119. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/next-best-engagement/open_pharma_plugins_next_best_engagement/fixtures/sample_universe.csv +0 -0
  120. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/next-best-engagement/open_pharma_plugins_next_best_engagement/tools/__init__.py +0 -0
  121. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/__init__.py +0 -0
  122. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/__main__.py +0 -0
  123. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/data.py +0 -0
  124. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/fixtures/constraints.csv +0 -0
  125. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/fixtures/current_alignment.csv +0 -0
  126. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/fixtures/hcps.csv +0 -0
  127. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/fixtures/reps.csv +0 -0
  128. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/geo.py +0 -0
  129. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/models.py +0 -0
  130. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/scoring.py +0 -0
  131. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/solver.py +0 -0
  132. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/tools/__init__.py +0 -0
  133. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/tools/ta_align.py +0 -0
  134. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/tools/ta_cluster.py +0 -0
  135. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/tools/ta_compare.py +0 -0
  136. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/tools/ta_evaluate.py +0 -0
  137. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/tools/ta_status.py +0 -0
  138. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/capabilities/territory-alignment/open_pharma_plugins_territory_alignment/tools/ta_visualize.py +0 -0
  139. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/open_pharma_plugins.egg-info/SOURCES.txt +0 -0
  140. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/open_pharma_plugins.egg-info/dependency_links.txt +0 -0
  141. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/open_pharma_plugins.egg-info/entry_points.txt +0 -0
  142. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/open_pharma_plugins.egg-info/requires.txt +0 -0
  143. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/open_pharma_plugins.egg-info/top_level.txt +0 -0
  144. {open_pharma_plugins-2.2.0 → open_pharma_plugins-2.2.1}/src/shared/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: open-pharma-plugins
3
- Version: 2.2.0
3
+ Version: 2.2.1
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
@@ -77,7 +77,7 @@ def handle(arguments: dict[str, Any]) -> list[dict[str, Any]]:
77
77
  "openfda": {
78
78
  "url": "https://api.fda.gov/drug",
79
79
  "auth": "optional API key",
80
- "rate_limit": "240/min with key, 40/min without",
80
+ "rate_limit": "240/min; 1,000/day per IP without key or 120,000/day per key",
81
81
  },
82
82
  "dailymed": {"url": "https://dailymed.nlm.nih.gov/dailymed/services/v2", "auth": "none"},
83
83
  "pubmed": {
@@ -1,13 +1,13 @@
1
1
  from mcp_framework import build_registry
2
2
 
3
- __version__ = "1.0.1"
3
+ __version__ = "1.0.2"
4
4
 
5
5
  SPECS, get_handler, list_tools = build_registry(__name__, ["tools"])
6
6
 
7
7
  USAGE_NOTE = (
8
8
  "Next-Best-Engagement recommends which HCPs to engage, through which "
9
- "channel, by which rep, subject to capacity, consent, and frequency "
10
- "constraints. Load an HCP universe (CSV or built-in fixtures), then "
9
+ "channel, by which rep, subject to visit capacity, explicit consent, "
10
+ "and minimum-gap constraints. Load an HCP universe (CSV or built-in fixtures), then "
11
11
  "generate an optimised engagement plan."
12
12
  )
13
13
 
@@ -7,6 +7,7 @@ from datetime import date, timedelta
7
7
  from typing import Any
8
8
 
9
9
  from ._scoring import score_hcp
10
+ from ._universe import fingerprint_universe
10
11
  from .models import (
11
12
  ConstraintConfig,
12
13
  EngagementPlan,
@@ -24,9 +25,11 @@ def generate_plan(
24
25
  rep_info: dict[str, dict[str, Any]],
25
26
  config: ConstraintConfig,
26
27
  today: date | None = None,
28
+ universe_generation: int = 0,
27
29
  ) -> EngagementPlan:
28
30
  """Generate an optimized engagement plan."""
29
31
  today = today or date.today()
32
+ _validate_universe(universe, today)
30
33
  period_start = today
31
34
  period_end = today + timedelta(days=config.period_days)
32
35
 
@@ -35,19 +38,26 @@ def generate_plan(
35
38
  rep_id: int(info["capacity"] * weeks_in_period) for rep_id, info in rep_info.items()
36
39
  }
37
40
 
38
- hcp_action_counts: dict[str, list[str]] = {}
39
- tier_counts: dict[str, int] = {}
40
- for hcp in universe:
41
- tier_counts[hcp.tier] = tier_counts.get(hcp.tier, 0) + 1
42
-
43
- tier_coverage_actual: dict[str, float] = {t: 0.0 for t in tier_counts}
44
-
41
+ tier_coverage_actual = {hcp.tier: 0.0 for hcp in universe}
45
42
  scored = []
46
43
  for hcp in universe:
47
44
  s, rationale = score_hcp(hcp, config, tier_coverage_actual, today)
48
45
  scored.append((s, hcp, rationale))
49
46
  scored.sort(key=lambda x: x[0], reverse=True)
50
47
 
48
+ # Coverage targets and metrics use the same pre-allocation action-eligibility
49
+ # boundary as the planning pass: consent/gap checks plus the score threshold.
50
+ eligible_universe = [
51
+ hcp
52
+ for score, hcp, _ in scored
53
+ if _eligibility_reason(hcp, config, today) is None and score >= config.no_action_threshold
54
+ ]
55
+ tier_counts: dict[str, int] = {}
56
+ for hcp in eligible_universe:
57
+ tier_counts[hcp.tier] = tier_counts.get(hcp.tier, 0) + 1
58
+
59
+ tier_coverage_actual = {t: 0.0 for t in tier_counts}
60
+
51
61
  engagements: list[PlannedEngagement] = []
52
62
  unassigned: list[UnassignedHCP] = []
53
63
  assigned_hcps: set[str] = set()
@@ -72,12 +82,21 @@ def generate_plan(
72
82
  if tier_assigned >= target_count:
73
83
  break
74
84
 
85
+ eligibility_reason = _eligibility_reason(hcp, config, today)
86
+ if eligibility_reason is not None:
87
+ _record_unassigned(hcp, eligibility_reason, unassigned, no_action_reasons)
88
+ assigned_hcps.add(hcp.hcp_id)
89
+ continue
90
+ if s < config.no_action_threshold:
91
+ _record_unassigned(hcp, "below_threshold", unassigned, no_action_reasons)
92
+ assigned_hcps.add(hcp.hcp_id)
93
+ continue
94
+
75
95
  result = _try_assign(
76
96
  hcp,
77
97
  s,
78
98
  rationale,
79
99
  config,
80
- hcp_action_counts,
81
100
  rep_remaining,
82
101
  rep_info,
83
102
  period_start,
@@ -96,8 +115,8 @@ def generate_plan(
96
115
 
97
116
  engagements.append(engagement)
98
117
  assigned_hcps.add(hcp.hcp_id)
99
- rep_remaining[engagement.rep_id] -= 1
100
- hcp_action_counts.setdefault(hcp.hcp_id, []).append(action)
118
+ if action == "in_person_visit":
119
+ rep_remaining[engagement.rep_id] -= 1
101
120
  tier_assigned += 1
102
121
 
103
122
  # Re-score remaining HCPs with updated coverage for Phase 2
@@ -117,6 +136,10 @@ def generate_plan(
117
136
  remaining.sort(key=lambda x: x[0], reverse=True)
118
137
 
119
138
  for s, hcp, rationale in remaining:
139
+ eligibility_reason = _eligibility_reason(hcp, config, today)
140
+ if eligibility_reason is not None:
141
+ _record_unassigned(hcp, eligibility_reason, unassigned, no_action_reasons)
142
+ continue
120
143
  if s < config.no_action_threshold:
121
144
  _record_unassigned(hcp, "below_threshold", unassigned, no_action_reasons)
122
145
  continue
@@ -126,7 +149,6 @@ def generate_plan(
126
149
  s,
127
150
  rationale,
128
151
  config,
129
- hcp_action_counts,
130
152
  rep_remaining,
131
153
  rep_info,
132
154
  period_start,
@@ -143,11 +165,12 @@ def generate_plan(
143
165
 
144
166
  engagements.append(engagement)
145
167
  assigned_hcps.add(hcp.hcp_id)
146
- rep_remaining[engagement.rep_id] -= 1
147
- hcp_action_counts.setdefault(hcp.hcp_id, []).append(action)
168
+ if action == "in_person_visit":
169
+ rep_remaining[engagement.rep_id] -= 1
148
170
 
149
171
  metrics = _build_metrics(
150
- universe,
172
+ len(universe),
173
+ eligible_universe,
151
174
  engagements,
152
175
  rep_info,
153
176
  rep_remaining,
@@ -159,6 +182,8 @@ def generate_plan(
159
182
  )
160
183
 
161
184
  return EngagementPlan(
185
+ universe_fingerprint=fingerprint_universe(universe),
186
+ universe_generation=universe_generation,
162
187
  period_start=period_start,
163
188
  period_end=period_end,
164
189
  generated_at=today.isoformat(),
@@ -171,28 +196,38 @@ def generate_plan(
171
196
  # ---- internal helpers ----
172
197
 
173
198
 
199
+ def _validate_universe(universe: list[UniverseRow], today: date) -> None:
200
+ seen_hcp_ids: set[str] = set()
201
+ for hcp in universe:
202
+ if hcp.hcp_id in seen_hcp_ids:
203
+ raise ValueError(f"Duplicate hcp_id: {hcp.hcp_id}")
204
+ seen_hcp_ids.add(hcp.hcp_id)
205
+ for field_name in ("last_visit_date", "last_email_date", "last_meeting_date"):
206
+ touch_date = getattr(hcp, field_name)
207
+ if touch_date is not None and touch_date > today:
208
+ raise ValueError(f"{field_name} is future-dated relative to the planning date")
209
+
210
+
174
211
  def _try_assign(
175
212
  hcp: UniverseRow,
176
213
  score: float,
177
214
  rationale: str,
178
215
  config: ConstraintConfig,
179
- hcp_action_counts: dict[str, list[str]],
180
216
  rep_remaining: dict[str, int],
181
217
  rep_info: dict[str, dict[str, Any]],
182
218
  period_start: date,
183
219
  period_end: date,
184
220
  today: date,
185
221
  ) -> tuple[str, PlannedEngagement] | None:
186
- """Try to assign an action to an HCP. Returns None if no rep has capacity."""
187
- rep_id = _pick_rep(hcp, rep_remaining, rep_info)
188
- if rep_id is None:
222
+ """Assign to the declared rep, enforcing capacity only for in-person visits."""
223
+ action = _select_action(hcp, config, today)
224
+ if action == "in_person_visit" and rep_remaining.get(hcp.rep_id, 0) <= 0:
189
225
  return None
190
226
 
191
- action = _select_action(hcp, config, hcp_action_counts.get(hcp.hcp_id, []), today)
192
227
  engagement = _create_engagement(
193
228
  hcp,
194
229
  action,
195
- rep_id,
230
+ hcp.rep_id,
196
231
  rep_info,
197
232
  score,
198
233
  rationale,
@@ -205,13 +240,9 @@ def _try_assign(
205
240
  def _select_action(
206
241
  hcp: UniverseRow,
207
242
  config: ConstraintConfig,
208
- prior_actions: list[str],
209
243
  today: date,
210
244
  ) -> str:
211
245
  """Rule cascade: pick the best action for this HCP."""
212
- visit_count = sum(1 for a in prior_actions if a == "in_person_visit")
213
- email_count = sum(1 for a in prior_actions if a == "approved_email")
214
-
215
246
  last_visit = hcp.last_visit_date
216
247
  days_since_visit = (today - last_visit).days if last_visit else 999
217
248
 
@@ -224,51 +255,28 @@ def _select_action(
224
255
  return "no_action"
225
256
 
226
257
  # Rule 1: High-tier + overdue for visit + phone consent → in-person visit
227
- if hcp.tier in ("A", "B") and days_since_visit >= 45 and hcp.consent_phone:
228
- if visit_count < config.max_visits_per_hcp:
229
- return "in_person_visit"
258
+ if hcp.tier in ("A", "B") and days_since_visit >= 45 and hcp.consent_phone is True:
259
+ return "in_person_visit"
230
260
 
231
261
  # Rule 2: Recently visited + email consent → email follow-up
232
- if days_since_visit < 30 and hcp.consent_email:
233
- if email_count < config.max_emails_per_hcp:
234
- return "approved_email"
262
+ if days_since_visit < 30 and hcp.consent_email is True:
263
+ return "approved_email"
235
264
 
236
265
  # Rule 3: Not recently visited + phone consent → remote meeting
237
- if days_since_visit >= 30 and hcp.consent_phone:
238
- if visit_count < config.max_visits_per_hcp:
239
- return "remote_meeting"
266
+ if days_since_visit >= 30 and hcp.consent_phone is True:
267
+ return "remote_meeting"
240
268
 
241
269
  # Rule 4: Email fallback
242
- if hcp.consent_email and email_count < config.max_emails_per_hcp:
270
+ if hcp.consent_email is True:
243
271
  return "approved_email"
244
272
 
245
273
  # Rule 5: Phone fallback
246
- if hcp.consent_phone and visit_count < config.max_visits_per_hcp:
274
+ if hcp.consent_phone is True:
247
275
  return "in_person_visit"
248
276
 
249
277
  return "no_action"
250
278
 
251
279
 
252
- def _pick_rep(
253
- hcp: UniverseRow,
254
- rep_remaining: dict[str, int],
255
- rep_info: dict[str, dict[str, Any]],
256
- ) -> str | None:
257
- """Pick a rep with capacity — prefer the assigned rep, then least-loaded in territory."""
258
- if hcp.rep_id in rep_remaining and rep_remaining[hcp.rep_id] > 0:
259
- return hcp.rep_id
260
-
261
- candidates = [
262
- (rep_id, rep_remaining.get(rep_id, 0))
263
- for rep_id, info in rep_info.items()
264
- if hcp.territory_id in info["territories"] and rep_remaining.get(rep_id, 0) > 0
265
- ]
266
- if not candidates:
267
- return None
268
- candidates.sort(key=lambda x: x[1], reverse=True)
269
- return candidates[0][0]
270
-
271
-
272
280
  def _create_engagement(
273
281
  hcp: UniverseRow,
274
282
  action: str,
@@ -292,36 +300,47 @@ def _create_engagement(
292
300
 
293
301
  rep_name = rep_info.get(rep_id, {}).get("name", rep_id)
294
302
 
295
- data: dict[str, Any] = {
296
- "hcp_id": hcp.hcp_id,
297
- "hcp_name": hcp.hcp_name,
298
- "specialty": hcp.specialty,
299
- "tier": hcp.tier,
300
- "territory_id": hcp.territory_id,
301
- "rep_id": rep_id,
302
- "rep_name": rep_name,
303
- "action_type": action,
304
- "priority": priority,
305
- "score": round(score, 3),
306
- "suggested_window_start": period_start,
307
- "suggested_window_end": period_end,
308
- "rationale": rationale,
309
- }
310
- data.update(hcp.model_extra)
303
+ data: dict[str, Any] = dict(hcp.model_extra or {})
304
+ data.update(
305
+ {
306
+ "hcp_id": hcp.hcp_id,
307
+ "hcp_name": hcp.hcp_name,
308
+ "specialty": hcp.specialty,
309
+ "tier": hcp.tier,
310
+ "territory_id": hcp.territory_id,
311
+ "rep_id": rep_id,
312
+ "rep_name": rep_name,
313
+ "action_type": action,
314
+ "priority": priority,
315
+ "score": round(score, 3),
316
+ "suggested_window_start": period_start,
317
+ "suggested_window_end": period_end,
318
+ "rationale": rationale,
319
+ }
320
+ )
311
321
  return PlannedEngagement.model_validate(data)
312
322
 
313
323
 
314
324
  def _no_action_reason(hcp: UniverseRow, config: ConstraintConfig, today: date) -> str:
315
325
  """Determine why an HCP got no_action."""
326
+ return _eligibility_reason(hcp, config, today) or "channels_exhausted"
327
+
328
+
329
+ def _eligibility_reason(hcp: UniverseRow, config: ConstraintConfig, today: date) -> str | None:
330
+ """Return a fail-closed eligibility reason before score-based filtering."""
316
331
  most_recent = max(
317
332
  (d for d in [hcp.last_visit_date, hcp.last_email_date, hcp.last_meeting_date] if d),
318
333
  default=None,
319
334
  )
320
335
  if most_recent and (today - most_recent).days < config.min_gap_days:
321
336
  return "too_recent"
337
+ if hcp.consent_email is True or hcp.consent_phone is True:
338
+ return None
339
+ if hcp.consent_email is None or hcp.consent_phone is None:
340
+ return "missing_consent"
322
341
  if not hcp.consent_email and not hcp.consent_phone:
323
342
  return "no_consent"
324
- return "channels_exhausted"
343
+ return None
325
344
 
326
345
 
327
346
  def _record_unassigned(
@@ -335,7 +354,8 @@ def _record_unassigned(
335
354
 
336
355
 
337
356
  def _build_metrics(
338
- universe: list[UniverseRow],
357
+ total_universe: int,
358
+ eligible_universe: list[UniverseRow],
339
359
  engagements: list[PlannedEngagement],
340
360
  rep_info: dict[str, dict[str, Any]],
341
361
  rep_remaining: dict[str, int],
@@ -345,7 +365,7 @@ def _build_metrics(
345
365
  tier_counts: dict[str, int],
346
366
  target_map: dict[str, float],
347
367
  ) -> PlanMetrics:
348
- total_eligible = len(universe)
368
+ total_eligible = len(eligible_universe)
349
369
  total_planned = len(engagements)
350
370
 
351
371
  # Coverage by tier
@@ -389,6 +409,7 @@ def _build_metrics(
389
409
  coverage_pct = total_planned / total_eligible if total_eligible else 0.0
390
410
 
391
411
  return PlanMetrics(
412
+ total_universe=total_universe,
392
413
  total_eligible=total_eligible,
393
414
  total_planned=total_planned,
394
415
  coverage_pct=round(coverage_pct, 3),
@@ -7,29 +7,43 @@ import io
7
7
  from datetime import date
8
8
  from pathlib import Path
9
9
  from typing import Any
10
+ from uuid import uuid4
10
11
 
11
- from shared.filesystem import atomic_write_text, ensure_private_dir
12
+ from shared.filesystem import ensure_private_dir, exclusive_write_text, remove_files
12
13
 
13
- from .models import EngagementPlan
14
+ from .models import EngagementPlan, is_formula_unsafe_text
14
15
 
15
16
 
16
17
  def render_csv(plan: EngagementPlan, output_dir: str) -> dict[str, str]:
17
- """Write the plan to CSV files. Returns paths of written files."""
18
+ """Write the plan to a new pair of CSV files. Returns paths of written files."""
18
19
  from datetime import datetime, timezone
19
20
 
21
+ engagements_content = _engagements_csv_content(plan)
22
+ summary_content = _summary_csv_content(plan)
20
23
  out = ensure_private_dir(Path(output_dir))
21
24
 
22
- ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
23
- engagements_path = out / f"engagements_{ts}.csv"
24
- summary_path = out / f"plan_summary_{ts}.csv"
25
-
26
- _write_engagements_csv(plan, engagements_path)
27
- _write_summary_csv(plan, summary_path)
25
+ for _ in range(10):
26
+ run_id = f"{datetime.now(timezone.utc):%Y%m%d_%H%M%S}_{uuid4().hex}"
27
+ engagements_path = out / f"engagements_{run_id}.csv"
28
+ summary_path = out / f"plan_summary_{run_id}.csv"
29
+ created_paths: list[Path] = []
30
+ try:
31
+ exclusive_write_text(engagements_path, engagements_content)
32
+ created_paths.append(engagements_path)
33
+ exclusive_write_text(summary_path, summary_content)
34
+ created_paths.append(summary_path)
35
+ except FileExistsError:
36
+ remove_files(created_paths)
37
+ except Exception:
38
+ remove_files(created_paths)
39
+ raise
40
+ else:
41
+ return {
42
+ "engagements_csv": str(engagements_path),
43
+ "summary_csv": str(summary_path),
44
+ }
28
45
 
29
- return {
30
- "engagements_csv": str(engagements_path),
31
- "summary_csv": str(summary_path),
32
- }
46
+ raise FileExistsError("Could not allocate unique plan-render output paths.")
33
47
 
34
48
 
35
49
  def render_json(plan: EngagementPlan) -> str:
@@ -54,27 +68,23 @@ _DEFAULT_ENGAGEMENT_COLUMNS = [
54
68
  ]
55
69
 
56
70
 
57
- def _write_engagements_csv(plan: EngagementPlan, path: Path) -> None:
71
+ def _engagements_csv_content(plan: EngagementPlan) -> str:
58
72
  buffer = io.StringIO(newline="")
59
- if not plan.engagements:
60
- writer = csv.DictWriter(buffer, fieldnames=_DEFAULT_ENGAGEMENT_COLUMNS)
61
- writer.writeheader()
62
- atomic_write_text(path, buffer.getvalue())
63
- return
64
-
65
73
  rows = [_engagement_to_row(e) for e in plan.engagements]
66
- fieldnames = list(rows[0].keys())
74
+ extra_columns = sorted({key for row in rows for key in row if key not in _DEFAULT_ENGAGEMENT_COLUMNS})
75
+ fieldnames = [*_DEFAULT_ENGAGEMENT_COLUMNS, *extra_columns]
67
76
 
68
77
  writer = csv.DictWriter(buffer, fieldnames=fieldnames)
69
78
  writer.writeheader()
70
79
  writer.writerows(rows)
71
- atomic_write_text(path, buffer.getvalue())
80
+ return buffer.getvalue()
72
81
 
73
82
 
74
- def _write_summary_csv(plan: EngagementPlan, path: Path) -> None:
83
+ def _summary_csv_content(plan: EngagementPlan) -> str:
75
84
  m = plan.metrics
76
85
  rows: list[dict[str, Any]] = []
77
86
 
87
+ rows.append({"section": "overview", "metric": "total_universe", "value": m.total_universe})
78
88
  rows.append({"section": "overview", "metric": "total_eligible", "value": m.total_eligible})
79
89
  rows.append({"section": "overview", "metric": "total_planned", "value": m.total_planned})
80
90
  rows.append({"section": "overview", "metric": "coverage_pct", "value": m.coverage_pct})
@@ -129,7 +139,7 @@ def _write_summary_csv(plan: EngagementPlan, path: Path) -> None:
129
139
  writer = csv.DictWriter(buffer, fieldnames=["section", "metric", "value"])
130
140
  writer.writeheader()
131
141
  writer.writerows({key: _safe_csv_cell(value) for key, value in row.items()} for row in rows)
132
- atomic_write_text(path, buffer.getvalue())
142
+ return buffer.getvalue()
133
143
 
134
144
 
135
145
  def _engagement_to_row(e: Any) -> dict[str, Any]:
@@ -144,6 +154,6 @@ def _engagement_to_row(e: Any) -> dict[str, Any]:
144
154
 
145
155
  def _safe_csv_cell(value: Any) -> Any:
146
156
  """Prevent spreadsheet applications from interpreting exported text as a formula."""
147
- if isinstance(value, str) and value.startswith(("=", "+", "-", "@", "\t", "\r", "\n")):
157
+ if isinstance(value, str) and is_formula_unsafe_text(value):
148
158
  return "'" + value
149
159
  return value
@@ -42,7 +42,7 @@ def score_hcp(
42
42
  velocity = 0.3
43
43
  factors.append(("engagement_velocity", velocity, velocity * w.engagement_velocity))
44
44
 
45
- # 4. Channel freshness — penalise single-channel repetition
45
+ # 4. Channel diversity — reward recent use of more than one channel type
46
46
  touches = [
47
47
  (hcp.last_visit_date, "visit"),
48
48
  (hcp.last_email_date, "email"),
@@ -50,11 +50,11 @@ def score_hcp(
50
50
  ]
51
51
  recent_channels = [ch for d, ch in touches if d and (today - d).days <= 90]
52
52
  if len(recent_channels) <= 1:
53
- freshness = 0.8 if recent_channels else 1.0
53
+ diversity = 0.8 if recent_channels else 1.0
54
54
  else:
55
55
  unique = len(set(recent_channels))
56
- freshness = unique / len(recent_channels)
57
- factors.append(("channel_freshness", freshness, freshness * w.channel_freshness))
56
+ diversity = unique / len(recent_channels)
57
+ factors.append(("channel_diversity", diversity, diversity * w.channel_diversity))
58
58
 
59
59
  # 5. Coverage debt — how far is this tier from its target?
60
60
  target_map = {