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
shared/env.py ADDED
@@ -0,0 +1,217 @@
1
+ """Environment access for the shared library and every capability.
2
+
3
+ Import `get_env` (call-time accessor for any env var) instead of calling os.getenv across the
4
+ codebase, so this stays the one place that reads the environment. Capability-private env vars
5
+ keep their own defaults in the capability that owns them.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from collections.abc import Iterable
12
+
13
+
14
+ def get_env(name: str, default: str | None = None) -> str | None:
15
+ """Env config, read at CALL time. Precedence: environment > user config file > default."""
16
+ val = os.environ.get(name)
17
+ return val if val is not None else _config().get(name, default)
18
+
19
+
20
+ # ── User config file (~/.open-pharma-plugins/config): KEY=VALUE lines, read when a var isn't in the
21
+ # environment. Location is fixed (not per-OS like cache_dir): "where is the config" can't live in
22
+ # the config, and pointing at it via env var would reintroduce the inheritance problem it solves. ──
23
+
24
+
25
+ def config_dir() -> str:
26
+ """Fixed config dir (~/.open-pharma-plugins), overridable via OPEN_PHARMA_CONFIG_DIR."""
27
+ return os.path.expanduser(os.environ.get("OPEN_PHARMA_CONFIG_DIR") or "~/.open-pharma-plugins")
28
+
29
+
30
+ def config_file() -> str:
31
+ """Config file path, overridable via OPEN_PHARMA_CONFIG (full path)."""
32
+ override = os.environ.get("OPEN_PHARMA_CONFIG")
33
+ return os.path.expanduser(override) if override else os.path.join(config_dir(), "config")
34
+
35
+
36
+ def _parse_config(text: str) -> dict[str, str]:
37
+ """Minimal dotenv parse: KEY=VALUE per line; skip blank/# lines; strip `export ` and quotes.
38
+ Stdlib-only on purpose — the package floor is 3.10, so tomllib (3.11+) isn't guaranteed."""
39
+ out: dict[str, str] = {}
40
+ for line in text.splitlines():
41
+ line = line.strip().removeprefix("export ").lstrip()
42
+ if not line or line.startswith("#") or "=" not in line:
43
+ continue
44
+ key, _, val = line.partition("=")
45
+ val = val.strip()
46
+ if len(val) >= 2 and val[0] == val[-1] and val[0] in "'\"":
47
+ val = val[1:-1]
48
+ if key.strip():
49
+ out[key.strip()] = val
50
+ return out
51
+
52
+
53
+ _config_cache: dict[str, str] | None = None
54
+
55
+
56
+ def _config() -> dict[str, str]:
57
+ """Parsed config, loaded once and cached (empty if missing/unreadable)."""
58
+ global _config_cache
59
+ if _config_cache is None:
60
+ try:
61
+ with open(config_file(), encoding="utf-8") as f:
62
+ _config_cache = _parse_config(f.read())
63
+ except (OSError, UnicodeDecodeError):
64
+ _config_cache = {}
65
+ return _config_cache
66
+
67
+
68
+ _CONFIG_HEADER = "# open-pharma-plugins config — KEY=VALUE per line, read when the var isn't in the environment.\n\n"
69
+
70
+
71
+ def _write_config(path: str, merged: dict[str, str]) -> None:
72
+ """Atomically write `merged` as a sorted dotenv file (0600) and invalidate the cache."""
73
+ global _config_cache
74
+ from shared.filesystem import atomic_write_text, ensure_private_dir
75
+
76
+ ensure_private_dir(os.path.dirname(path))
77
+ content = _CONFIG_HEADER + "".join(f"{key}={merged[key]}\n" for key in sorted(merged))
78
+ atomic_write_text(path, content)
79
+ _config_cache = None
80
+
81
+
82
+ def set_config(values: dict[str, str | None]) -> str:
83
+ """Merge non-None `values` into the config file (atomic, 0600) and return its path."""
84
+ path = config_file()
85
+ try:
86
+ with open(path, encoding="utf-8") as f:
87
+ merged = _parse_config(f.read())
88
+ except OSError:
89
+ merged = {}
90
+ merged.update({k: v for k, v in values.items() if v is not None})
91
+ _write_config(path, merged)
92
+ return path
93
+
94
+
95
+ def del_config(keys: Iterable[str]) -> str:
96
+ """Remove `keys` from the config file (atomic, 0600) and return its path."""
97
+ path = config_file()
98
+ try:
99
+ with open(path, encoding="utf-8") as f:
100
+ merged = _parse_config(f.read())
101
+ except OSError:
102
+ return path
103
+ present = [k for k in keys if k in merged]
104
+ if not present:
105
+ return path
106
+ for k in present:
107
+ del merged[k]
108
+ _write_config(path, merged)
109
+ return path
110
+
111
+
112
+ # ── Config-field catalog: the ONE declarative list of user-settable config vars, driving the
113
+ # interactive `--setup` (grouped) and documenting what belongs in the config file. install.sh mirrors
114
+ # this list (CONFIG_SPEC) for its own editor — keep the two in sync when adding a var. ──
115
+ CONFIG_FIELDS: list[tuple[str, bool, str, str, str]] = [
116
+ # Web Search (used by hcp-intelligence and competitive-intelligence)
117
+ (
118
+ "OPEN_PHARMA_SEARCH_BACKEND",
119
+ False,
120
+ "Web Search",
121
+ "auto",
122
+ "text search backend (auto: serper > tavily > exa; or choose one)",
123
+ ),
124
+ ("SERPER_API_KEY", True, "Web Search", "", "Serper web search API key"),
125
+ ("TAVILY_API_KEY", True, "Web Search", "", "Tavily web search API key"),
126
+ ("EXA_API_KEY", True, "Web Search", "", "Exa web search API key"),
127
+ # HCP Intelligence
128
+ (
129
+ "OPENROUTER_API_KEY",
130
+ True,
131
+ "HCP Intelligence",
132
+ "",
133
+ "OpenRouter API key for optional batch profile synthesis",
134
+ ),
135
+ (
136
+ "OPENROUTER_BASE_URL",
137
+ False,
138
+ "HCP Intelligence",
139
+ "https://openrouter.ai/api/v1",
140
+ "OpenRouter-compatible API base URL for optional batch profile synthesis",
141
+ ),
142
+ (
143
+ "NCBI_API_KEY",
144
+ False,
145
+ "HCP Intelligence",
146
+ "",
147
+ "NCBI E-utilities API key (optional; raises PubMed rate limit from 3 to 10 req/s)",
148
+ ),
149
+ (
150
+ "OPEN_PHARMA_HCP_DATA_DIR",
151
+ False,
152
+ "HCP Intelligence",
153
+ "",
154
+ "mutable enrichment data directory (default: ~/.open-pharma-plugins/hcp-intelligence)",
155
+ ),
156
+ # Field Training
157
+ (
158
+ "OPEN_PHARMA_TRAINING_CONTENT_DIR",
159
+ False,
160
+ "Field Training",
161
+ "",
162
+ "content store directory for ingested training documents (default: ~/.open-pharma-plugins/training-content)",
163
+ ),
164
+ # Campaign Studio
165
+ (
166
+ "OPEN_PHARMA_CAMPAIGN_STORE_DIR",
167
+ False,
168
+ "Campaign Studio",
169
+ "",
170
+ "root directory for campaign briefs, claims, and rendered assets (default: ~/.open-pharma-plugins/campaign-studio)",
171
+ ),
172
+ # Next Best Engagement
173
+ (
174
+ "OPEN_PHARMA_NBE_OUTPUT_DIR",
175
+ False,
176
+ "Next Best Engagement",
177
+ "",
178
+ "directory for exported engagement plans (default: ~/.open-pharma-plugins/next-best-engagement)",
179
+ ),
180
+ # Territory Alignment
181
+ (
182
+ "OPEN_PHARMA_TA_DATA_DIR",
183
+ False,
184
+ "Territory Alignment",
185
+ "",
186
+ "directory containing hcps.csv, reps.csv, current_alignment.csv, constraints.csv (default: built-in fixtures)",
187
+ ),
188
+ (
189
+ "OPEN_PHARMA_TA_SCENARIOS_DIR",
190
+ False,
191
+ "Territory Alignment",
192
+ "",
193
+ "directory for saved alignment scenarios (default: ~/.open-pharma-plugins/territory-alignment/scenarios)",
194
+ ),
195
+ # Competitive Intelligence
196
+ (
197
+ "OPEN_PHARMA_CI_DATA_DIR",
198
+ False,
199
+ "Competitive Intelligence",
200
+ "",
201
+ "watchlist, report, and cache directory (default: ~/.open-pharma-plugins/competitive-intelligence)",
202
+ ),
203
+ (
204
+ "OPENFDA_API_KEY",
205
+ False,
206
+ "Competitive Intelligence",
207
+ "",
208
+ "openFDA API key (optional; raises rate limit from 40 to 240 req/min)",
209
+ ),
210
+ (
211
+ "CI_CACHE_TTL_HOURS",
212
+ False,
213
+ "Competitive Intelligence",
214
+ "24",
215
+ "hours before cached API responses expire (default: 24)",
216
+ ),
217
+ ]
shared/filesystem.py ADDED
@@ -0,0 +1,110 @@
1
+ """Private, traversal-safe filesystem primitives for capability runtime data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import tempfile
8
+ from pathlib import Path
9
+ from typing import Any
10
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
11
+
12
+ _SENSITIVE_FRAGMENTS = ("api_key", "apikey", "access_token", "auth_token", "authorization")
13
+
14
+
15
+ def ensure_private_dir(path: str | Path) -> Path:
16
+ """Create a directory and restrict it to the current user where supported."""
17
+ directory = Path(path).expanduser()
18
+ directory.mkdir(parents=True, exist_ok=True)
19
+ if os.name != "nt":
20
+ directory.chmod(0o700)
21
+ return directory
22
+
23
+
24
+ def validate_component(value: str, *, label: str = "path component") -> str:
25
+ """Accept one non-special path component, never a path fragment."""
26
+ if not isinstance(value, str) or not value or value in {".", ".."}:
27
+ raise ValueError(f"{label} must be a non-empty file-name component")
28
+ if "\x00" in value or "/" in value or "\\" in value or Path(value).name != value:
29
+ raise ValueError(f"{label} must not contain path separators")
30
+ return value
31
+
32
+
33
+ def contained_path(root: str | Path, *components: str) -> Path:
34
+ """Build a path from validated components and prove it remains under root."""
35
+ base = Path(root).expanduser().resolve()
36
+ clean = [validate_component(component) for component in components]
37
+ candidate = base.joinpath(*clean).resolve()
38
+ if not candidate.is_relative_to(base):
39
+ raise ValueError("path escapes its configured root")
40
+ return candidate
41
+
42
+
43
+ def atomic_write_text(path: str | Path, content: str, *, encoding: str = "utf-8") -> Path:
44
+ return _atomic_write(Path(path), content.encode(encoding))
45
+
46
+
47
+ def atomic_write_bytes(path: str | Path, content: bytes) -> Path:
48
+ return _atomic_write(Path(path), content)
49
+
50
+
51
+ def atomic_write_json(path: str | Path, data: Any) -> Path:
52
+ return atomic_write_text(path, json.dumps(data, indent=2, ensure_ascii=False, default=str))
53
+
54
+
55
+ def _atomic_write(path: Path, content: bytes) -> Path:
56
+ parent = ensure_private_dir(path.expanduser().parent)
57
+ destination = parent / path.name
58
+ fd, temporary = tempfile.mkstemp(prefix=f".{destination.name}.", dir=parent)
59
+ temporary_path = Path(temporary)
60
+ try:
61
+ if os.name != "nt":
62
+ os.fchmod(fd, 0o600)
63
+ with os.fdopen(fd, "wb") as handle:
64
+ handle.write(content)
65
+ handle.flush()
66
+ os.fsync(handle.fileno())
67
+ os.replace(temporary_path, destination)
68
+ if os.name != "nt":
69
+ destination.chmod(0o600)
70
+ finally:
71
+ temporary_path.unlink(missing_ok=True)
72
+ return destination
73
+
74
+
75
+ def sanitize_url(url: str) -> str:
76
+ """Return a URL with credential-like query parameters removed."""
77
+ try:
78
+ parsed = urlsplit(url)
79
+ query = [(key, value) for key, value in parse_qsl(parsed.query, keep_blank_values=True) if not _sensitive(key)]
80
+ return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, urlencode(query), parsed.fragment))
81
+ except (TypeError, ValueError):
82
+ return "redacted-source"
83
+
84
+
85
+ def sanitize_mapping(value: Any) -> Any:
86
+ """Recursively remove credential-bearing mapping entries before persistence."""
87
+ if isinstance(value, dict):
88
+ return {key: sanitize_mapping(item) for key, item in value.items() if not _sensitive(str(key))}
89
+ if isinstance(value, list):
90
+ return [sanitize_mapping(item) for item in value]
91
+ if isinstance(value, tuple):
92
+ return [sanitize_mapping(item) for item in value]
93
+ return value
94
+
95
+
96
+ def json_for_html_script(value: Any) -> str:
97
+ """Serialize data for a script block without allowing an HTML end-tag breakout."""
98
+ return (
99
+ json.dumps(value, ensure_ascii=False, default=str)
100
+ .replace("&", "\\u0026")
101
+ .replace("<", "\\u003c")
102
+ .replace(">", "\\u003e")
103
+ .replace("\u2028", "\\u2028")
104
+ .replace("\u2029", "\\u2029")
105
+ )
106
+
107
+
108
+ def _sensitive(key: str) -> bool:
109
+ normalized = key.lower().replace("-", "_")
110
+ return any(fragment in normalized for fragment in _SENSITIVE_FRAGMENTS)