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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. mcp_framework.py +495 -0
  2. open_pharma_plugins-2.2.0.dist-info/METADATA +135 -0
  3. open_pharma_plugins-2.2.0.dist-info/RECORD +136 -0
  4. open_pharma_plugins-2.2.0.dist-info/WHEEL +5 -0
  5. open_pharma_plugins-2.2.0.dist-info/entry_points.txt +8 -0
  6. open_pharma_plugins-2.2.0.dist-info/licenses/LICENSE +202 -0
  7. open_pharma_plugins-2.2.0.dist-info/top_level.txt +8 -0
  8. open_pharma_plugins_campaign_studio/__init__.py +14 -0
  9. open_pharma_plugins_campaign_studio/__main__.py +11 -0
  10. open_pharma_plugins_campaign_studio/_campaign_store.py +162 -0
  11. open_pharma_plugins_campaign_studio/_claim_engine.py +262 -0
  12. open_pharma_plugins_campaign_studio/_renderer.py +119 -0
  13. open_pharma_plugins_campaign_studio/fixtures/brand_kit/legal.json +21 -0
  14. open_pharma_plugins_campaign_studio/fixtures/brand_kit/logo.svg +4 -0
  15. open_pharma_plugins_campaign_studio/fixtures/brand_kit/palette.json +11 -0
  16. open_pharma_plugins_campaign_studio/fixtures/brand_kit/product.png +1 -0
  17. open_pharma_plugins_campaign_studio/fixtures/brand_kit/typography.json +14 -0
  18. open_pharma_plugins_campaign_studio/fixtures/sample_approved_claims.json +119 -0
  19. open_pharma_plugins_campaign_studio/models/__init__.py +34 -0
  20. open_pharma_plugins_campaign_studio/models/_common.py +12 -0
  21. open_pharma_plugins_campaign_studio/models/brief.py +72 -0
  22. open_pharma_plugins_campaign_studio/models/claims.py +15 -0
  23. open_pharma_plugins_campaign_studio/models/copy.py +47 -0
  24. open_pharma_plugins_campaign_studio/models/journey.py +21 -0
  25. open_pharma_plugins_campaign_studio/models/message.py +25 -0
  26. open_pharma_plugins_campaign_studio/models/mlr.py +29 -0
  27. open_pharma_plugins_campaign_studio/models/validation.py +32 -0
  28. open_pharma_plugins_campaign_studio/policy/rules.json +79 -0
  29. open_pharma_plugins_campaign_studio/templates/banner.svg.j2 +26 -0
  30. open_pharma_plugins_campaign_studio/templates/email.html.j2 +54 -0
  31. open_pharma_plugins_campaign_studio/tools/__init__.py +0 -0
  32. open_pharma_plugins_campaign_studio/tools/create_campaign_brief.py +212 -0
  33. open_pharma_plugins_campaign_studio/tools/generate_audience_journey.py +129 -0
  34. open_pharma_plugins_campaign_studio/tools/generate_channel_copy.py +199 -0
  35. open_pharma_plugins_campaign_studio/tools/generate_message_architecture.py +121 -0
  36. open_pharma_plugins_campaign_studio/tools/package_mlr_submission.py +230 -0
  37. open_pharma_plugins_campaign_studio/tools/render_banner.py +99 -0
  38. open_pharma_plugins_campaign_studio/tools/render_email.py +101 -0
  39. open_pharma_plugins_campaign_studio/tools/render_poster.py +222 -0
  40. open_pharma_plugins_campaign_studio/tools/retrieve_approved_claims.py +71 -0
  41. open_pharma_plugins_campaign_studio/tools/retrieve_brand_components.py +76 -0
  42. open_pharma_plugins_campaign_studio/tools/validate_claims_and_fair_balance.py +285 -0
  43. open_pharma_plugins_competitive_intelligence/__init__.py +13 -0
  44. open_pharma_plugins_competitive_intelligence/__main__.py +11 -0
  45. open_pharma_plugins_competitive_intelligence/_artifacts.py +87 -0
  46. open_pharma_plugins_competitive_intelligence/_cache.py +144 -0
  47. open_pharma_plugins_competitive_intelligence/_clinical_trials.py +569 -0
  48. open_pharma_plugins_competitive_intelligence/_dailymed.py +260 -0
  49. open_pharma_plugins_competitive_intelligence/_fda.py +255 -0
  50. open_pharma_plugins_competitive_intelligence/_pubmed.py +342 -0
  51. open_pharma_plugins_competitive_intelligence/_regulatory.py +140 -0
  52. open_pharma_plugins_competitive_intelligence/_runs.py +278 -0
  53. open_pharma_plugins_competitive_intelligence/_transport.py +83 -0
  54. open_pharma_plugins_competitive_intelligence/_watchlist.py +113 -0
  55. open_pharma_plugins_competitive_intelligence/_web_search.py +331 -0
  56. open_pharma_plugins_competitive_intelligence/models.py +525 -0
  57. open_pharma_plugins_competitive_intelligence/tools/__init__.py +0 -0
  58. open_pharma_plugins_competitive_intelligence/tools/ci_extract_events.py +247 -0
  59. open_pharma_plugins_competitive_intelligence/tools/ci_landscape.py +195 -0
  60. open_pharma_plugins_competitive_intelligence/tools/ci_refresh.py +101 -0
  61. open_pharma_plugins_competitive_intelligence/tools/ci_report.py +402 -0
  62. open_pharma_plugins_competitive_intelligence/tools/ci_scan_news.py +48 -0
  63. open_pharma_plugins_competitive_intelligence/tools/ci_scan_publications.py +46 -0
  64. open_pharma_plugins_competitive_intelligence/tools/ci_scan_regulatory.py +64 -0
  65. open_pharma_plugins_competitive_intelligence/tools/ci_scan_trials.py +85 -0
  66. open_pharma_plugins_competitive_intelligence/tools/ci_status.py +106 -0
  67. open_pharma_plugins_competitive_intelligence/tools/ci_timeline.py +453 -0
  68. open_pharma_plugins_competitive_intelligence/tools/ci_track.py +121 -0
  69. open_pharma_plugins_competitive_intelligence/tools/ci_trial_detail.py +55 -0
  70. open_pharma_plugins_field_training/__init__.py +13 -0
  71. open_pharma_plugins_field_training/__main__.py +11 -0
  72. open_pharma_plugins_field_training/_content_store.py +131 -0
  73. open_pharma_plugins_field_training/_grounding.py +75 -0
  74. open_pharma_plugins_field_training/_html_renderers.py +546 -0
  75. open_pharma_plugins_field_training/fixtures/sample_product_message.pdf +156 -0
  76. open_pharma_plugins_field_training/fixtures/sample_training_deck.pptx +0 -0
  77. open_pharma_plugins_field_training/models.py +265 -0
  78. open_pharma_plugins_field_training/tools/__init__.py +0 -0
  79. open_pharma_plugins_field_training/tools/get_document_page.py +67 -0
  80. open_pharma_plugins_field_training/tools/ingest_document.py +147 -0
  81. open_pharma_plugins_field_training/tools/list_documents.py +51 -0
  82. open_pharma_plugins_field_training/tools/render_output.py +118 -0
  83. open_pharma_plugins_field_training/tools/search_content.py +57 -0
  84. open_pharma_plugins_hcp_intelligence/__init__.py +14 -0
  85. open_pharma_plugins_hcp_intelligence/__main__.py +11 -0
  86. open_pharma_plugins_hcp_intelligence/_crm_store.py +70 -0
  87. open_pharma_plugins_hcp_intelligence/batch.py +891 -0
  88. open_pharma_plugins_hcp_intelligence/batch_cli.py +221 -0
  89. open_pharma_plugins_hcp_intelligence/batch_csv.py +206 -0
  90. open_pharma_plugins_hcp_intelligence/fixtures/sample_accounts.csv +27 -0
  91. open_pharma_plugins_hcp_intelligence/models.py +356 -0
  92. open_pharma_plugins_hcp_intelligence/tools/__init__.py +0 -0
  93. open_pharma_plugins_hcp_intelligence/tools/get_account.py +48 -0
  94. open_pharma_plugins_hcp_intelligence/tools/list_accounts.py +66 -0
  95. open_pharma_plugins_hcp_intelligence/tools/search_clinical_trials.py +175 -0
  96. open_pharma_plugins_hcp_intelligence/tools/search_congresses.py +134 -0
  97. open_pharma_plugins_hcp_intelligence/tools/search_grants.py +181 -0
  98. open_pharma_plugins_hcp_intelligence/tools/search_guidelines.py +238 -0
  99. open_pharma_plugins_hcp_intelligence/tools/search_hco_web.py +73 -0
  100. open_pharma_plugins_hcp_intelligence/tools/search_hcp_web.py +199 -0
  101. open_pharma_plugins_hcp_intelligence/tools/search_orcid.py +214 -0
  102. open_pharma_plugins_hcp_intelligence/tools/search_publications.py +207 -0
  103. open_pharma_plugins_hcp_intelligence/tools/update_account.py +84 -0
  104. open_pharma_plugins_next_best_engagement/__init__.py +14 -0
  105. open_pharma_plugins_next_best_engagement/__main__.py +11 -0
  106. open_pharma_plugins_next_best_engagement/_optimizer.py +400 -0
  107. open_pharma_plugins_next_best_engagement/_renderer.py +149 -0
  108. open_pharma_plugins_next_best_engagement/_scoring.py +82 -0
  109. open_pharma_plugins_next_best_engagement/_universe.py +145 -0
  110. open_pharma_plugins_next_best_engagement/fixtures/sample_universe.csv +81 -0
  111. open_pharma_plugins_next_best_engagement/models.py +135 -0
  112. open_pharma_plugins_next_best_engagement/tools/__init__.py +0 -0
  113. open_pharma_plugins_next_best_engagement/tools/load_universe.py +47 -0
  114. open_pharma_plugins_next_best_engagement/tools/recommend_engagements.py +80 -0
  115. open_pharma_plugins_next_best_engagement/tools/render_plan.py +90 -0
  116. open_pharma_plugins_territory_alignment/__init__.py +14 -0
  117. open_pharma_plugins_territory_alignment/__main__.py +11 -0
  118. open_pharma_plugins_territory_alignment/data.py +300 -0
  119. open_pharma_plugins_territory_alignment/fixtures/constraints.csv +11 -0
  120. open_pharma_plugins_territory_alignment/fixtures/current_alignment.csv +81 -0
  121. open_pharma_plugins_territory_alignment/fixtures/hcps.csv +81 -0
  122. open_pharma_plugins_territory_alignment/fixtures/reps.csv +9 -0
  123. open_pharma_plugins_territory_alignment/geo.py +175 -0
  124. open_pharma_plugins_territory_alignment/models.py +201 -0
  125. open_pharma_plugins_territory_alignment/scoring.py +125 -0
  126. open_pharma_plugins_territory_alignment/solver.py +504 -0
  127. open_pharma_plugins_territory_alignment/tools/__init__.py +0 -0
  128. open_pharma_plugins_territory_alignment/tools/ta_align.py +138 -0
  129. open_pharma_plugins_territory_alignment/tools/ta_cluster.py +247 -0
  130. open_pharma_plugins_territory_alignment/tools/ta_compare.py +173 -0
  131. open_pharma_plugins_territory_alignment/tools/ta_evaluate.py +119 -0
  132. open_pharma_plugins_territory_alignment/tools/ta_status.py +34 -0
  133. open_pharma_plugins_territory_alignment/tools/ta_visualize.py +504 -0
  134. shared/__init__.py +11 -0
  135. shared/env.py +217 -0
  136. shared/filesystem.py +110 -0
mcp_framework.py ADDED
@@ -0,0 +1,495 @@
1
+ """Shared MCP-server framework for open-pharma-plugins, built on the SDK's FastMCP.
2
+
3
+ A server is just its tool subpackages plus a thin `__init__.py` (`__version__`, the discovered
4
+ `SPECS`, a few optional hooks). This module provides what every server would otherwise duplicate:
5
+
6
+ - build_registry(import_name, tool_packages) — auto-discover TOOL+handle modules
7
+ - serve(...) — bridge tools onto FastMCP + run stdio
8
+ - run_main(import_name) — console / `python3 <dir>` entry dispatcher
9
+ - tool_schema(model) — Pydantic model -> advertised inputSchema
10
+
11
+ Each tool module exports `TOOL = {"name", "description", "args": <PydanticModel>}` + `handle(dict)`.
12
+ Depends only on the `mcp` SDK (bundles FastMCP + pydantic) + anyio, so servers stay independent.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ __version__ = "2.2.0" # distribution/release-train version; plugin versions are per capability
18
+
19
+ import asyncio
20
+ import importlib
21
+ import importlib.util
22
+ import inspect
23
+ import json
24
+ import logging
25
+ import os
26
+ import pkgutil
27
+ import shutil
28
+ import signal
29
+ import sys
30
+ import warnings
31
+ from typing import Annotated
32
+
33
+ import anyio
34
+ from pydantic.json_schema import GenerateJsonSchema
35
+
36
+ __all__ = [
37
+ "build_registry",
38
+ "serve",
39
+ "run_main",
40
+ "tool_schema",
41
+ "system_report",
42
+ "system_startup_warnings",
43
+ ]
44
+
45
+
46
+ # Schema: Pydantic model -> the JSON inputSchema advertised to the model.
47
+ class _ToolSchemaGen(GenerateJsonSchema):
48
+ """Emit LLM-clean tool schemas straight from pydantic, no post-hoc rewriting: drop auto-generated
49
+ `title`s (field- and class-level), unwrap `Optional`'s null branch, and omit `default: null`.
50
+ `field_title_should_be_set`/`nullable_schema`/`default_schema` are documented override points;
51
+ `_update_class_schema` (nested class titles) is private with no public equivalent, so the
52
+ tool_schema tests assert the output stays title-free across pydantic upgrades. Paired with
53
+ union_format='primitive_type_array' so `float | str` -> {"type": ["number", "string"]}."""
54
+
55
+ def field_title_should_be_set(self, schema) -> bool:
56
+ return False
57
+
58
+ def _update_class_schema(self, json_schema, *args, **kwargs) -> None:
59
+ super()._update_class_schema(json_schema, *args, **kwargs)
60
+ json_schema.pop("title", None)
61
+
62
+ def nullable_schema(self, schema) -> dict:
63
+ return self.generate_inner(schema["schema"]) # Optional[X] -> X, drop the anyOf null branch
64
+
65
+ def default_schema(self, schema) -> dict:
66
+ js = super().default_schema(schema)
67
+ if js.get("default", ...) is None:
68
+ js.pop("default", None) # omit `default: null`; real defaults are kept
69
+ return js
70
+
71
+
72
+ def tool_schema(model) -> dict:
73
+ """Build the advertised MCP inputSchema from a Pydantic model: generated title-free with
74
+ Optional/`default: null` already stripped (see _ToolSchemaGen), then inline `$ref`/`$defs`
75
+ and drop the top-level model description. Per-property `description`s are kept."""
76
+ schema = _inline_defs(model.model_json_schema(schema_generator=_ToolSchemaGen, union_format="primitive_type_array"))
77
+ schema.pop("description", None)
78
+ return schema
79
+
80
+
81
+ def _inline_defs(schema: dict) -> dict:
82
+ """Replace every `#/$defs/*` `$ref` with the referenced definition (inlined) and drop the
83
+ `$defs` block."""
84
+ defs = schema.pop("$defs", None)
85
+ if not defs:
86
+ return schema
87
+
88
+ def walk(node):
89
+ if isinstance(node, dict):
90
+ ref = node.get("$ref")
91
+ if isinstance(ref, str) and ref.startswith("#/$defs/"):
92
+ target = defs[ref.split("/")[-1]]
93
+ sibling = {k: v for k, v in node.items() if k != "$ref"}
94
+ return walk({**target, **sibling})
95
+ return {k: walk(v) for k, v in node.items()}
96
+ if isinstance(node, list):
97
+ return [walk(v) for v in node]
98
+ return node
99
+
100
+ return walk(schema)
101
+
102
+
103
+ # Discovery: scan subpackages for tool modules (TOOL dict + handle fn).
104
+ class ToolSpec:
105
+ """One discovered tool: its advertised schema, Pydantic args model, and handler."""
106
+
107
+ __slots__ = ("name", "description", "input_schema", "args_model", "handle")
108
+
109
+ def __init__(self, name, description, input_schema, args_model, handle):
110
+ self.name = name
111
+ self.description = description
112
+ self.input_schema = input_schema
113
+ self.args_model = args_model
114
+ self.handle = handle
115
+
116
+ @property
117
+ def meta(self) -> dict:
118
+ return {"name": self.name, "description": self.description, "inputSchema": self.input_schema}
119
+
120
+
121
+ def build_registry(import_name: str, tool_packages: list[str]):
122
+ """Auto-discover tool modules (exporting TOOL + handle) under the given subpackages of
123
+ `import_name`. Returns (specs, get_handler, list_tools): a list of ToolSpec, a
124
+ `get_handler(name) -> handle | None`, and a `list_tools() -> [tool meta dict]`. Raises on
125
+ a module that exports only one of TOOL/handle (a typo that would otherwise vanish
126
+ silently) or on a duplicate tool name (an ambiguous registry)."""
127
+ specs: list[ToolSpec] = []
128
+ seen: dict[str, str] = {}
129
+ for pkg_name in tool_packages:
130
+ pkg = importlib.import_module(f"{import_name}.{pkg_name}")
131
+ for info in pkgutil.iter_modules(pkg.__path__):
132
+ full = f"{import_name}.{pkg_name}.{info.name}"
133
+ mod = importlib.import_module(full)
134
+ has_tool, has_handle = hasattr(mod, "TOOL"), hasattr(mod, "handle")
135
+ if has_tool != has_handle:
136
+ missing, present = ("handle", "TOOL") if has_tool else ("TOOL", "handle")
137
+ raise RuntimeError(
138
+ f"{full} exports {present} but not {missing}: a tool module must export both "
139
+ "(or neither, for a helper module)."
140
+ )
141
+ if not has_tool:
142
+ continue
143
+ spec = _spec_from_module(mod)
144
+ if spec.name in seen:
145
+ raise RuntimeError(f"duplicate tool name {spec.name!r}: defined in {seen[spec.name]} and {full}.")
146
+ seen[spec.name] = full
147
+ specs.append(spec)
148
+ handlers = {s.name: s.handle for s in specs}
149
+
150
+ def list_tools() -> list[dict]:
151
+ return [s.meta for s in specs]
152
+
153
+ return specs, handlers.get, list_tools
154
+
155
+
156
+ def _spec_from_module(mod) -> ToolSpec:
157
+ t = mod.TOOL
158
+ model = t["args"]
159
+ return ToolSpec(t["name"], t.get("description", ""), tool_schema(model), model, mod.handle)
160
+
161
+
162
+ # Runtime: bridge specs onto FastMCP and run over stdio.
163
+ def _to_content_block(block: dict):
164
+ """Convert a handler's raw content dict into an SDK content block (text/image;
165
+ anything else falls back to JSON text so a stray block can't crash a call)."""
166
+ import mcp.types as types
167
+
168
+ btype = block.get("type")
169
+ if btype == "image" and "data" in block:
170
+ # Codex MCP image-detail extension ("original" instead of its default "high"):
171
+ # https://github.com/openai/codex/blob/44cb66e4edc061d39ae38de949b47f6f94416553/codex-rs/protocol/src/models.rs#L2106-L2187
172
+ return types.ImageContent(
173
+ type="image",
174
+ data=block["data"],
175
+ mimeType=block.get("mimeType", "image/jpeg"),
176
+ _meta={"codex/imageDetail": "original"},
177
+ )
178
+ if btype == "text":
179
+ return types.TextContent(type="text", text=block.get("text", ""))
180
+ return types.TextContent(type="text", text=json.dumps(block, ensure_ascii=False))
181
+
182
+
183
+ async def _run_handle(handle, arguments: dict):
184
+ raw = await anyio.to_thread.run_sync(lambda: handle(arguments))
185
+ return [_to_content_block(b) for b in raw]
186
+
187
+
188
+ def _make_wrapper(spec: ToolSpec):
189
+ """Synthesize the async tool fn FastMCP registers: its signature (from the Pydantic
190
+ args model) drives schema-generation + validation; its body dumps the validated model
191
+ to a plain dict, runs the handle off the event loop, and converts the result."""
192
+ handle = spec.handle
193
+ model = spec.args_model
194
+ params = [
195
+ inspect.Parameter(
196
+ name,
197
+ inspect.Parameter.KEYWORD_ONLY,
198
+ default=(inspect.Parameter.empty if field.is_required() else field.default),
199
+ annotation=Annotated[field.annotation, field],
200
+ )
201
+ for name, field in model.model_fields.items()
202
+ ]
203
+
204
+ async def wrapper(**kwargs):
205
+ # validated model -> plain dict for the handle(dict) API
206
+ return await _run_handle(handle, model(**kwargs).model_dump(exclude_none=True))
207
+
208
+ wrapper.__signature__ = inspect.Signature(params)
209
+ wrapper.__name__ = spec.name
210
+ return wrapper
211
+
212
+
213
+ def serve(server_name, version, specs, *, transport=None, on_start=None):
214
+ """Run an MCP stdio server bridging discovered tool specs onto FastMCP.
215
+
216
+ FastMCP validates inputs against each spec's args model; we override the advertised schema with
217
+ our normalized `tool_schema`. `transport` is an optional (read, write) context-manager factory
218
+ (defaults to the SDK stdio_server; the vision server passes its streaming writer); `on_start`
219
+ runs once after the transport opens."""
220
+ from mcp.server.fastmcp import FastMCP
221
+ from mcp.server.stdio import stdio_server
222
+
223
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", stream=sys.stderr)
224
+ # Ignore SIGPIPE: a client disconnect surfaces as an exception (handled in run_main),
225
+ # not a process kill. Unix-only.
226
+ if hasattr(signal, "SIGPIPE"):
227
+ signal.signal(signal.SIGPIPE, signal.SIG_IGN)
228
+
229
+ mcp = FastMCP(server_name)
230
+ mcp._mcp_server.version = version # reported in the initialize handshake
231
+ with warnings.catch_warnings():
232
+ # wrapper defaults aren't JSON-serializable; we override .parameters below, so silence it.
233
+ warnings.filterwarnings("ignore", message="Default value .* is not JSON serializable")
234
+ for spec in specs:
235
+ mcp.add_tool(_make_wrapper(spec), name=spec.name, description=spec.description, structured_output=False)
236
+ mcp._tool_manager.get_tool(spec.name).parameters = spec.input_schema
237
+
238
+ low = mcp._mcp_server
239
+
240
+ async def _run():
241
+ tctx = transport or stdio_server
242
+ async with tctx() as (read, write):
243
+ if on_start is not None:
244
+ on_start()
245
+ await low.run(read, write, low.create_initialization_options())
246
+
247
+ asyncio.run(_run())
248
+
249
+
250
+ # System dependencies: a declarative table -> --check-system report + startup warnings.
251
+ # Some capabilities need a *system* tool pip/uv can't install. A capability declares a SYSTEM_DEPS
252
+ # list (+ optional SYSTEM_DEPS_NOTE); the
253
+ # framework renders --check-system (run_main) and warns at startup about tools missing while their
254
+ # Python extra is installed. Each entry — required: label, tools, hint; the rest are optional and
255
+ # default to the common case (a system binary with no Python gating, warned at startup when missing):
256
+ # label (required) human-readable capability the tool powers
257
+ # tools (required) system binaries (any-of: present if ANY resolves on PATH)
258
+ # hint (required) install command
259
+ # extra pip group that pulls its Python side; omit / None = core, always relevant (shown [core])
260
+ # probe import name to detect that Python side; omit / None = no Python dep (entry always active)
261
+ # startup omit / True = warn at startup when missing; False = report-only (--check-system only)
262
+ def _tool_present(tool: str) -> bool:
263
+ if tool == "__playwright_chromium__":
264
+ # The browser lives in the playwright cache, not on PATH — can't detect it, so report-only.
265
+ return False
266
+ return shutil.which(tool) is not None
267
+
268
+
269
+ def _extra_installed(probe) -> bool:
270
+ """True if the entry's Python side is importable (or it has no Python dep)."""
271
+ return probe is None or importlib.util.find_spec(probe) is not None
272
+
273
+
274
+ def _entry_ok(dep: dict) -> bool:
275
+ return any(_tool_present(t) for t in dep["tools"])
276
+
277
+
278
+ def system_report(deps, *, note: str = "") -> str:
279
+ """Render `--check-system` for a SYSTEM_DEPS table, scoped to installed extras.
280
+
281
+ ✓/✗ per entry whose Python extra is importable (plus entries with no Python dep); entries whose
282
+ extra isn't installed collapse into one trailing line. `note` is an optional footer."""
283
+ if not deps:
284
+ return "No system tools required."
285
+ lines = ["System-tool dependency check (pip/uv cannot install these):", ""]
286
+ dormant: list[str] = []
287
+ for dep in deps:
288
+ if not _extra_installed(dep.get("probe")):
289
+ if dep.get("extra"):
290
+ dormant.append(dep["extra"])
291
+ continue
292
+ ok = _entry_ok(dep)
293
+ mark = "✓" if ok else "✗"
294
+ extra = f" [{dep['extra']}]" if dep.get("extra") else " [core]"
295
+ lines.append(f" {mark} {dep['label']}{extra}")
296
+ if not ok:
297
+ real = [t for t in dep["tools"] if t != "__playwright_chromium__"]
298
+ lines.append(f" needs: {' or '.join(real) or 'playwright chromium'}")
299
+ lines.append(f" install: {dep['hint']}")
300
+ if dormant:
301
+ lines.append("")
302
+ lines.append(f" (extras not installed — add to enable: {', '.join(sorted(set(dormant)))})")
303
+ if note:
304
+ lines.append("")
305
+ lines.append(note)
306
+ return "\n".join(lines)
307
+
308
+
309
+ def system_startup_warnings(deps) -> list[str]:
310
+ """Missing system tools whose Python extra IS installed — what the user will hit at runtime.
311
+ Skips startup=False entries."""
312
+ out = []
313
+ for dep in deps or []:
314
+ if not dep.get("startup", True):
315
+ continue
316
+ if _extra_installed(dep.get("probe")) and not _entry_ok(dep):
317
+ out.append(f"{dep['label']} — install: {dep['hint']}")
318
+ return out
319
+
320
+
321
+ def usage(import_name: str, note: str = "", *, launchable: bool = False) -> str:
322
+ """Standard --help / no-tty text for a server, derived from its entry name.
323
+ `note` is the server-specific tail (install hints, required env vars, …).
324
+ `launchable` adds the --launch-app line for capabilities that can start their own app."""
325
+ entry = import_name.replace("_", "-")
326
+ launch_opt = f"Usage: {entry} [--version | --help | --check-system | --setup | --set KEY=VALUE … | --unset KEY …"
327
+ launch_opt += " | --launch-app]\n" if launchable else "]\n"
328
+ text = (
329
+ f"{entry} — MCP server (stdio transport)\n\n"
330
+ f"{launch_opt}"
331
+ " --check-system report system tools pip can't install, + config status\n"
332
+ " --setup interactively write the full config to ~/.open-pharma-plugins/config\n"
333
+ " --set KEY=VALUE non-interactively write config entries (for automation)\n"
334
+ " --unset KEY … non-interactively remove config entries\n"
335
+ )
336
+ if launchable:
337
+ text += (
338
+ " --launch-app bring up the live app (with the bundled addon) this server talks to;\n"
339
+ " pass --launch-app --help for its options\n"
340
+ )
341
+ return f"{text}\n{note}" if note else text
342
+
343
+
344
+ def config_report(entry: str) -> str:
345
+ """`--check-system` tail: config location without assuming a model provider."""
346
+ from shared.env import CONFIG_FIELDS, config_file
347
+
348
+ path = config_file()
349
+ lines = [f"User config: {path}" + ("" if os.path.exists(path) else f" (none yet — `{entry} --setup`)")]
350
+ lines.append(f" {len(CONFIG_FIELDS)} capability settings available; run `{entry} --setup` to review them")
351
+ return "\n".join(lines)
352
+
353
+
354
+ def _interactive_setup(entry: str) -> None:
355
+ """Prompt for every catalog field, grouped, and merge the answers into the user config file.
356
+
357
+ Iterates shared.env.CONFIG_FIELDS (the one declarative list) so new vars need no new prompt
358
+ code. Per field: blank keeps the current value, `-` clears it (removed, not written empty)."""
359
+ import getpass
360
+
361
+ from shared.env import CONFIG_FIELDS, config_file, del_config, get_env, set_config
362
+
363
+ print(f"{entry} setup → {config_file()}")
364
+ print("Enter a value, blank to keep the current one, or '-' to clear it. Ctrl-C to abort.")
365
+ values: dict[str, str | None] = {}
366
+ cleared: list[str] = []
367
+ group = None
368
+ try:
369
+ for key, secret, grp, default, desc in CONFIG_FIELDS:
370
+ if grp != group:
371
+ group = grp
372
+ print(f"\n— {grp} —")
373
+ cur = get_env(key)
374
+ if cur:
375
+ shown = "set" if secret else cur
376
+ elif default:
377
+ shown = f"default: {default}"
378
+ else:
379
+ shown = ""
380
+ hint = f" [{shown}]" if shown else ""
381
+ prompt = f" {key} ({desc}){hint}: "
382
+ v = (getpass.getpass(prompt) if secret else input(prompt)).strip()
383
+ if not v:
384
+ continue
385
+ if v == "-":
386
+ cleared.append(key)
387
+ else:
388
+ values[key] = v
389
+ except (EOFError, KeyboardInterrupt):
390
+ print("\naborted.")
391
+ return
392
+ if not values and not cleared:
393
+ print("\nNothing changed.")
394
+ return
395
+ path = set_config(values) if values else config_file()
396
+ if cleared:
397
+ path = del_config(cleared)
398
+ done = sorted(values) + [f"-{k}" for k in cleared]
399
+ print(f"\n✓ saved {', '.join(done)} → {path}")
400
+
401
+
402
+ def _check_system_text(pkg) -> str:
403
+ """`--check-system` text: an explicit check_system() override on the package wins; else
404
+ render its declarative SYSTEM_DEPS (+ optional SYSTEM_DEPS_NOTE footer); else nothing needed."""
405
+ check = getattr(pkg, "check_system", None)
406
+ if check is not None:
407
+ return check()
408
+ deps = getattr(pkg, "SYSTEM_DEPS", None)
409
+ if deps is not None:
410
+ return system_report(deps, note=getattr(pkg, "SYSTEM_DEPS_NOTE", ""))
411
+ return "No system tools required."
412
+
413
+
414
+ def run_main(import_name: str) -> None:
415
+ """Console / `python3 <dir>` entry: --version / --check-system / --setup / --help / serve.
416
+
417
+ Reads config off the package: required __version__, SPECS; optional SYSTEM_DEPS (+
418
+ SYSTEM_DEPS_NOTE), check_system() override, on_start(), transport, USAGE_NOTE."""
419
+ pkg = importlib.import_module(import_name)
420
+ argv = sys.argv[1:]
421
+ entry = import_name.replace("_", "-")
422
+ if "--version" in argv:
423
+ print(pkg.__version__)
424
+ return
425
+ if "--check-system" in argv:
426
+ print(_check_system_text(pkg))
427
+ print()
428
+ print(config_report(entry))
429
+ return
430
+ if "--setup" in argv:
431
+ _interactive_setup(entry)
432
+ return
433
+ if "--set" in argv:
434
+ from shared.env import set_config
435
+
436
+ pairs: dict[str, str] = {}
437
+ for a in argv:
438
+ k, sep, v = a.partition("=")
439
+ if sep and not k.startswith("-"):
440
+ pairs[k] = v
441
+ if not pairs:
442
+ print(f"usage: {entry} --set KEY=VALUE [KEY=VALUE …]")
443
+ return
444
+ print(f"✓ wrote {', '.join(sorted(pairs))} → {set_config(pairs)}")
445
+ return
446
+ if "--unset" in argv:
447
+ from shared.env import del_config
448
+
449
+ keys = [a for a in argv if not a.startswith("-") and "=" not in a]
450
+ if not keys:
451
+ print(f"usage: {entry} --unset KEY [KEY …]")
452
+ return
453
+ print(f"✓ removed {', '.join(sorted(keys))} → {del_config(keys)}")
454
+ return
455
+ if "--launch-app" in argv:
456
+ launch = getattr(pkg, "launch_app", None)
457
+ if launch is None:
458
+ print(f"{import_name.replace('_', '-')} has no launchable app.", file=sys.stderr)
459
+ sys.exit(2)
460
+ rest = [a for a in argv if a != "--launch-app"]
461
+ sys.exit(launch(rest) or 0)
462
+ if "--help" in argv or "-h" in argv or sys.stdin.isatty():
463
+ print(
464
+ usage(import_name, getattr(pkg, "USAGE_NOTE", ""), launchable=getattr(pkg, "launch_app", None) is not None)
465
+ )
466
+ if not ({"--help", "-h"} & set(argv)):
467
+ sys.exit(1)
468
+ return
469
+
470
+ log = logging.getLogger(import_name)
471
+ deps = getattr(pkg, "SYSTEM_DEPS", None)
472
+ pkg_on_start = getattr(pkg, "on_start", None)
473
+
474
+ def _on_start():
475
+ # warn (stderr) about missing system tools, then run any capability on_start
476
+ for w in system_startup_warnings(deps):
477
+ log.warning("system tool missing — %s", w)
478
+ if pkg_on_start is not None:
479
+ pkg_on_start()
480
+
481
+ try:
482
+ serve(
483
+ import_name,
484
+ pkg.__version__,
485
+ pkg.SPECS,
486
+ transport=getattr(pkg, "transport", None),
487
+ on_start=_on_start,
488
+ )
489
+ except (BrokenPipeError, IOError) as exc:
490
+ log.info("client disconnected (%s), shutting down", type(exc).__name__)
491
+ except KeyboardInterrupt:
492
+ pass
493
+ except Exception:
494
+ log.exception("server crashed unexpectedly")
495
+ sys.exit(1) # surface the crash to the supervising harness with a non-zero status
@@ -0,0 +1,135 @@
1
+ Metadata-Version: 2.4
2
+ Name: open-pharma-plugins
3
+ Version: 2.2.0
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
+ Author: EAI Commercial Team
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/PharmaGenAI/open-pharma-plugins
8
+ Project-URL: Repository, https://github.com/PharmaGenAI/open-pharma-plugins
9
+ Project-URL: Issues, https://github.com/PharmaGenAI/open-pharma-plugins/issues
10
+ Keywords: mcp,pharma,commercial,agent-skills,hcp
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Requires-Python: <3.14,>=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: mcp<2,>=1.0.0
24
+ Requires-Dist: anyio<5,>=4.0
25
+ Requires-Dist: pydantic<3,>=2.8
26
+ Provides-Extra: hcp-intelligence
27
+ Requires-Dist: requests<3,>=2.31; extra == "hcp-intelligence"
28
+ Provides-Extra: hcp-intelligence-synth
29
+ Requires-Dist: requests<3,>=2.31; extra == "hcp-intelligence-synth"
30
+ Requires-Dist: openai<4,>=1; extra == "hcp-intelligence-synth"
31
+ Provides-Extra: field-training
32
+ Requires-Dist: pypdfium2<6,>=4; extra == "field-training"
33
+ Requires-Dist: python-pptx<2,>=1; extra == "field-training"
34
+ Provides-Extra: field-training-fixtures
35
+ Requires-Dist: pypdfium2<6,>=4; extra == "field-training-fixtures"
36
+ Requires-Dist: python-pptx<2,>=1; extra == "field-training-fixtures"
37
+ Requires-Dist: reportlab<6,>=4; extra == "field-training-fixtures"
38
+ Provides-Extra: next-best-engagement
39
+ Provides-Extra: territory-alignment
40
+ Provides-Extra: competitive-intelligence
41
+ Requires-Dist: pypdfium2<6,>=4; extra == "competitive-intelligence"
42
+ Requires-Dist: python-docx<2,>=1; extra == "competitive-intelligence"
43
+ Provides-Extra: campaign-studio
44
+ Requires-Dist: jinja2<4,>=3.1; extra == "campaign-studio"
45
+ Requires-Dist: reportlab<6,>=4; extra == "campaign-studio"
46
+ Provides-Extra: all
47
+ Requires-Dist: requests<3,>=2.31; extra == "all"
48
+ Requires-Dist: openai<4,>=1; extra == "all"
49
+ Requires-Dist: pypdfium2<6,>=4; extra == "all"
50
+ Requires-Dist: python-pptx<2,>=1; extra == "all"
51
+ Requires-Dist: python-docx<2,>=1; extra == "all"
52
+ Requires-Dist: reportlab<6,>=4; extra == "all"
53
+ Requires-Dist: jinja2<4,>=3.1; extra == "all"
54
+ Dynamic: license-file
55
+
56
+ # Open Pharma Plugins
57
+
58
+ Agent Skills and MCP tools for pharmaceutical commercial operations.
59
+
60
+ > **Public beta.** Validate outputs before operational use. Campaign and field-training artifacts are drafts for qualified medical/legal/regulatory review, not automated approval.
61
+
62
+ ## Capabilities
63
+
64
+ | Capability | Tools | Description |
65
+ |---|---:|---|
66
+ | [HCP Intelligence](cookbooks/hcp-intelligence/usage.md) | 11 | Build evidence-backed HCP/HCO profiles from public sources |
67
+ | [Field Training](cookbooks/field-training/usage.md) | 5 | Turn approved PDF/PPTX paths into grounded learning packages, assessments, role-play kits, and scorecards |
68
+ | [Campaign Studio](cookbooks/campaign-studio/usage.md) | 11 | Create, validate, render, and package campaign drafts for MLR review |
69
+ | [Next-Best-Engagement](cookbooks/next-best-engagement/usage.md) | 3 | Score HCPs and produce consent-aware engagement plans |
70
+ | [Territory Alignment](cookbooks/territory-alignment/usage.md) | 6 | Compare HCP-to-rep assignments and plan visit clusters |
71
+ | [Competitive Intelligence](cookbooks/competitive-intelligence/usage.md) | 12 | Collect one evidence run and project reproducible briefings and timelines |
72
+
73
+ ## Architecture
74
+
75
+ <p align="center"><img src="docs/assets/architecture.svg" width="960" alt="Architecture diagram"></p>
76
+
77
+ Each source plugin bundles a Skill with one MCP server. The Python wheel contains the six MCP servers and their runtime fixtures; Skills, cookbooks, and marketplace manifests are installed from the repository rather than the wheel.
78
+
79
+ ## Quick start
80
+
81
+ Python 3.10–3.13 is supported. Install one or more MCP servers from the published distribution:
82
+
83
+ ```bash
84
+ python -m pip install "open-pharma-plugins[territory-alignment,competitive-intelligence]"
85
+ open-pharma-plugins-territory-alignment --version
86
+ ```
87
+
88
+ For the complete Skill + MCP plugin, download and inspect the guided installer before running it:
89
+
90
+ ```bash
91
+ curl -fsSLO https://raw.githubusercontent.com/PharmaGenAI/open-pharma-plugins/main/install.sh
92
+ less install.sh
93
+ bash install.sh
94
+ ```
95
+
96
+ The installer requires `uv`/`uvx`; it never installs a package manager on your behalf. See [Installation](docs/en/installation.md) for tag-pinned and source-checkout options.
97
+
98
+ ## Configuration and local data
99
+
100
+ Copy `.env.example` to `~/.open-pharma-plugins/config` and set only the providers you use. Process environment variables take precedence. Mutable data defaults to private capability directories under `~/.open-pharma-plugins`; files are written with mode `0600` and directories with `0700` where POSIX permissions are available.
101
+
102
+ Common settings include:
103
+
104
+ | Variable | Used by |
105
+ |---|---|
106
+ | `SERPER_API_KEY` / `TAVILY_API_KEY` / `EXA_API_KEY` | Web search |
107
+ | `OPENROUTER_API_KEY` / `OPENROUTER_BASE_URL` | Optional HCP batch profile synthesis |
108
+ | `NCBI_API_KEY` | PubMed |
109
+ | `OPENFDA_API_KEY` | openFDA |
110
+ | `OPEN_PHARMA_*_DIR` | Capability-specific mutable-data locations |
111
+
112
+ API keys are not persisted in competitive-intelligence cache metadata, evidence URLs, runs, or reports. Source queries are still sent to the selected external provider, so do not place secrets or unnecessary personal data in search terms. Reports and timelines can reuse one immutable run instead of repeating provider calls.
113
+
114
+ HCP batch extraction/synthesis defaults to `high` reasoning effort, a 120-second request timeout,
115
+ and zero SDK retries. An installed HCP plugin accepts user-supplied input/output paths through the
116
+ tag-pinned `open-pharma-plugins-hcp-batch` console and produces canonical account JSON,
117
+ `batch_summary.csv`, and `batch_manifest.json`. See the
118
+ [HCP batch guide](docs/en/hcp_batch.md) for dry-run, confirmation, provider, CSV, and resume rules.
119
+
120
+ ## Documentation
121
+
122
+ - [Installation](docs/en/installation.md)
123
+ - [Configuration](docs/en/configuration.md)
124
+ - [HCP batch processing and CSV review](docs/en/hcp_batch.md)
125
+ - [Data security and compliance boundaries](docs/en/data_security.md)
126
+ - [Local development](docs/en/local_development.md)
127
+ - [Testing](docs/en/testing.md)
128
+ - [Adding a capability](docs/en/how_to_add_new_capability.md)
129
+ - [Releasing](docs/en/releasing.md)
130
+ - [Manual harness setup](docs/en/manual_harnesses.md)
131
+ - [Chinese documentation](docs/zh/) · [Japanese documentation](docs/jp/)
132
+
133
+ ## License
134
+
135
+ Apache-2.0. See [LICENSE](LICENSE).