humane-proxy 0.2.2__tar.gz → 0.2.3__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 (55) hide show
  1. {humane_proxy-0.2.2/humane_proxy.egg-info → humane_proxy-0.2.3}/PKG-INFO +13 -4
  2. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/README.md +12 -3
  3. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/humane_proxy/__init__.py +1 -1
  4. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/humane_proxy/classifiers/pipeline.py +27 -6
  5. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/humane_proxy/config.py +12 -0
  6. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/humane_proxy/middleware/interceptor.py +1 -1
  7. {humane_proxy-0.2.2 → humane_proxy-0.2.3/humane_proxy.egg-info}/PKG-INFO +13 -4
  8. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/pyproject.toml +1 -1
  9. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/server.json +2 -2
  10. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/tests/test_embedding_classifier.py +76 -0
  11. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/tests/test_pipeline.py +129 -3
  12. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/LICENSE +0 -0
  13. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/NOTICE +0 -0
  14. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/humane_proxy/api/__init__.py +0 -0
  15. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/humane_proxy/api/admin.py +0 -0
  16. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/humane_proxy/classifiers/__init__.py +0 -0
  17. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/humane_proxy/classifiers/embedding_classifier.py +0 -0
  18. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/humane_proxy/classifiers/heuristics.py +0 -0
  19. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/humane_proxy/classifiers/models.py +0 -0
  20. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/humane_proxy/classifiers/stage3/__init__.py +0 -0
  21. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/humane_proxy/classifiers/stage3/base.py +0 -0
  22. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/humane_proxy/classifiers/stage3/llamaguard.py +0 -0
  23. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/humane_proxy/classifiers/stage3/openai_chat.py +0 -0
  24. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/humane_proxy/classifiers/stage3/openai_moderation.py +0 -0
  25. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/humane_proxy/cli.py +0 -0
  26. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/humane_proxy/config.yaml +0 -0
  27. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/humane_proxy/escalation/__init__.py +0 -0
  28. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/humane_proxy/escalation/local_db.py +0 -0
  29. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/humane_proxy/escalation/router.py +0 -0
  30. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/humane_proxy/escalation/webhooks.py +0 -0
  31. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/humane_proxy/integrations/__init__.py +0 -0
  32. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/humane_proxy/integrations/langchain.py +0 -0
  33. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/humane_proxy/mcp_server.py +0 -0
  34. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/humane_proxy/middleware/__init__.py +0 -0
  35. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/humane_proxy/risk/__init__.py +0 -0
  36. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/humane_proxy/risk/trajectory.py +0 -0
  37. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/humane_proxy.egg-info/SOURCES.txt +0 -0
  38. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/humane_proxy.egg-info/dependency_links.txt +0 -0
  39. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/humane_proxy.egg-info/entry_points.txt +0 -0
  40. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/humane_proxy.egg-info/requires.txt +0 -0
  41. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/humane_proxy.egg-info/top_level.txt +0 -0
  42. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/setup.cfg +0 -0
  43. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/tests/test_admin_api.py +0 -0
  44. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/tests/test_care_response.py +0 -0
  45. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/tests/test_cli.py +0 -0
  46. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/tests/test_config.py +0 -0
  47. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/tests/test_enhanced_webhooks.py +0 -0
  48. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/tests/test_heuristics.py +0 -0
  49. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/tests/test_interceptor.py +0 -0
  50. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/tests/test_local_db.py +0 -0
  51. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/tests/test_models.py +0 -0
  52. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/tests/test_router.py +0 -0
  53. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/tests/test_stage3.py +0 -0
  54. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/tests/test_trajectory.py +0 -0
  55. {humane_proxy-0.2.2 → humane_proxy-0.2.3}/tests/test_webhooks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: humane-proxy
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Lightweight, plug-and-play AI safety middleware that protects humans.
5
5
  Author-email: Vishisht Mishra <Vishisht16@users.noreply.github.com>
6
6
  License: Apache-2.0
@@ -56,6 +56,8 @@ HumaneProxy sits between your users and any LLM. When someone expresses self-har
56
56
  [![Python](https://img.shields.io/pypi/pyversions/humane-proxy.svg)](https://pypi.org/project/humane-proxy/)
57
57
  [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE)
58
58
  [![Tests](https://github.com/Vishisht16/Humane-Proxy/actions/workflows/tests.yaml/badge.svg)](https://github.com/Vishisht16/Humane-Proxy/actions/workflows/tests.yaml)
59
+ [![Humane-Proxy MCP server](https://glama.ai/mcp/servers/Vishisht16/Humane-Proxy/badges/card.svg)](https://glama.ai/mcp/servers/Vishisht16/Humane-Proxy)
60
+ [![Humane-Proxy MCP server](https://glama.ai/mcp/servers/Vishisht16/Humane-Proxy/badges/score.svg)](https://glama.ai/mcp/servers/Vishisht16/Humane-Proxy)
59
61
 
60
62
  ---
61
63
 
@@ -85,10 +87,13 @@ pip install humane-proxy
85
87
  # Scaffold config in your project directory
86
88
  humane-proxy init
87
89
 
88
- # Start the proxy (set LLM_API_KEY and LLM_API_URL in .env first)
90
+ # Start the reverse proxy server
91
+ # (requires LLM_API_KEY and LLM_API_URL in .env — these point to your upstream LLM)
89
92
  humane-proxy start
90
93
  ```
91
94
 
95
+ > **Note:** `LLM_API_KEY` and `LLM_API_URL` are only needed for the reverse proxy server (`humane-proxy start`). They tell HumaneProxy where to forward safe messages. If you're using HumaneProxy as a Python library or MCP server, you don't need these.
96
+
92
97
  ### As a Python library
93
98
 
94
99
  ```python
@@ -109,19 +114,21 @@ result = await proxy.check_async("How do I make a bomb")
109
114
 
110
115
  ## 3-Stage Cascade Pipeline
111
116
 
112
- HumaneProxy classifies every message through up to **3 stages**, each progressively more capable but also more expensive. Stages exit early when confident.
117
+ HumaneProxy classifies every message through up to **3 stages**, each progressively more capable but also more expensive.
113
118
 
114
119
  ```
115
120
  ┌──────────────────────────────────────────────────────────┐
116
121
  │ Stage 1 — Heuristics < 1ms │
117
122
  │ Keyword corpus + intent regex patterns │
118
123
  │ Always on. Catches clear cases instantly. │
124
+ │ Early-exit: definitive self_harm → block immediately. │
119
125
  └──────────────────────────────────────────────────────────┘
120
- ↓ (ambiguous or medium-score)
126
+ ↓ (all other messages when Stage 2 enabled)
121
127
  ┌──────────────────────────────────────────────────────────┐
122
128
  │ Stage 2 — Semantic Embeddings ~100ms │
123
129
  │ sentence-transformers cosine similarity │
124
130
  │ vs. curated anchor sentences (self-harm + criminal) │
131
+ │ ALL messages flow here when enabled. │
125
132
  │ Optional: pip install humane-proxy[ml] │
126
133
  └──────────────────────────────────────────────────────────┘
127
134
  ↓ (still ambiguous)
@@ -170,6 +177,8 @@ stage2:
170
177
 
171
178
  The model lazy-loads on first use. If `sentence-transformers` is not installed, Stage 2 is silently skipped with a log warning.
172
179
 
180
+ > **How Stage 2 works with Stage 1:** When you enable `[1, 2]`, **every message** that Stage 1 does not flag as definitive `self_harm` proceeds to the embedding classifier. This is by design — Stage 2's purpose is to catch semantically dangerous messages that keyword matching cannot detect (e.g. *"Nobody would notice if I disappeared"*). Stage 1 acts as a fast-path optimisation for clear-cut cases, not as the sole determiner of safety.
181
+
173
182
  ### Stage 3 — Reasoning LLM
174
183
 
175
184
  Set your API key and optionally configure the provider:
@@ -10,6 +10,8 @@ HumaneProxy sits between your users and any LLM. When someone expresses self-har
10
10
  [![Python](https://img.shields.io/pypi/pyversions/humane-proxy.svg)](https://pypi.org/project/humane-proxy/)
11
11
  [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE)
12
12
  [![Tests](https://github.com/Vishisht16/Humane-Proxy/actions/workflows/tests.yaml/badge.svg)](https://github.com/Vishisht16/Humane-Proxy/actions/workflows/tests.yaml)
13
+ [![Humane-Proxy MCP server](https://glama.ai/mcp/servers/Vishisht16/Humane-Proxy/badges/card.svg)](https://glama.ai/mcp/servers/Vishisht16/Humane-Proxy)
14
+ [![Humane-Proxy MCP server](https://glama.ai/mcp/servers/Vishisht16/Humane-Proxy/badges/score.svg)](https://glama.ai/mcp/servers/Vishisht16/Humane-Proxy)
13
15
 
14
16
  ---
15
17
 
@@ -39,10 +41,13 @@ pip install humane-proxy
39
41
  # Scaffold config in your project directory
40
42
  humane-proxy init
41
43
 
42
- # Start the proxy (set LLM_API_KEY and LLM_API_URL in .env first)
44
+ # Start the reverse proxy server
45
+ # (requires LLM_API_KEY and LLM_API_URL in .env — these point to your upstream LLM)
43
46
  humane-proxy start
44
47
  ```
45
48
 
49
+ > **Note:** `LLM_API_KEY` and `LLM_API_URL` are only needed for the reverse proxy server (`humane-proxy start`). They tell HumaneProxy where to forward safe messages. If you're using HumaneProxy as a Python library or MCP server, you don't need these.
50
+
46
51
  ### As a Python library
47
52
 
48
53
  ```python
@@ -63,19 +68,21 @@ result = await proxy.check_async("How do I make a bomb")
63
68
 
64
69
  ## 3-Stage Cascade Pipeline
65
70
 
66
- HumaneProxy classifies every message through up to **3 stages**, each progressively more capable but also more expensive. Stages exit early when confident.
71
+ HumaneProxy classifies every message through up to **3 stages**, each progressively more capable but also more expensive.
67
72
 
68
73
  ```
69
74
  ┌──────────────────────────────────────────────────────────┐
70
75
  │ Stage 1 — Heuristics < 1ms │
71
76
  │ Keyword corpus + intent regex patterns │
72
77
  │ Always on. Catches clear cases instantly. │
78
+ │ Early-exit: definitive self_harm → block immediately. │
73
79
  └──────────────────────────────────────────────────────────┘
74
- ↓ (ambiguous or medium-score)
80
+ ↓ (all other messages when Stage 2 enabled)
75
81
  ┌──────────────────────────────────────────────────────────┐
76
82
  │ Stage 2 — Semantic Embeddings ~100ms │
77
83
  │ sentence-transformers cosine similarity │
78
84
  │ vs. curated anchor sentences (self-harm + criminal) │
85
+ │ ALL messages flow here when enabled. │
79
86
  │ Optional: pip install humane-proxy[ml] │
80
87
  └──────────────────────────────────────────────────────────┘
81
88
  ↓ (still ambiguous)
@@ -124,6 +131,8 @@ stage2:
124
131
 
125
132
  The model lazy-loads on first use. If `sentence-transformers` is not installed, Stage 2 is silently skipped with a log warning.
126
133
 
134
+ > **How Stage 2 works with Stage 1:** When you enable `[1, 2]`, **every message** that Stage 1 does not flag as definitive `self_harm` proceeds to the embedding classifier. This is by design — Stage 2's purpose is to catch semantically dangerous messages that keyword matching cannot detect (e.g. *"Nobody would notice if I disappeared"*). Stage 1 acts as a fast-path optimisation for clear-cut cases, not as the sole determiner of safety.
135
+
127
136
  ### Stage 3 — Reasoning LLM
128
137
 
129
138
  Set your API key and optionally configure the provider:
@@ -16,7 +16,7 @@
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
- __version__ = "0.2.2"
19
+ __version__ = "0.2.3"
20
20
 
21
21
  # ---------------------------------------------------------------------------
22
22
  # Legacy API — keep backward compatibility with existing modules that call
@@ -149,8 +149,15 @@ class SafetyPipeline:
149
149
  self._stage3 = None
150
150
 
151
151
  def _show_stage3_warning(self) -> None:
152
- """Log a clear guidance message when Stage 3 is not active."""
152
+ """Log a clear guidance message when Stage 3 is not active.
153
+
154
+ Only shown when the user has explicitly enabled Stage 3 in their
155
+ ``enabled_stages`` config but no provider could be initialised
156
+ (typically because no API key is set).
157
+ """
153
158
  global _stage3_warning_shown
159
+ if 3 not in self.enabled_stages:
160
+ return # User didn't ask for Stage 3 — no warning needed.
154
161
  if self._stage3 is not None or _stage3_warning_shown:
155
162
  return
156
163
  _stage3_warning_shown = True
@@ -186,12 +193,21 @@ class SafetyPipeline:
186
193
  if result.category == "self_harm":
187
194
  return self._finalize(result, session_id, text)
188
195
 
189
- # Early exit: clear safe.
190
- if result.score <= self.stage1_ceiling and result.category == "safe":
196
+ # Early exit: clear safe — but only when no later stages can
197
+ # add value. When Stage 2 is enabled, messages that heuristics
198
+ # consider safe MUST still flow to the embedding classifier
199
+ # (that is its entire purpose: catching semantically dangerous
200
+ # content that keyword matching misses).
201
+ stage2_enabled = 2 in self.enabled_stages and self._stage2 is not None
202
+ if (
203
+ not stage2_enabled
204
+ and result.score <= self.stage1_ceiling
205
+ and result.category == "safe"
206
+ ):
191
207
  return self._finalize(result, session_id, text)
192
208
 
193
209
  # Stage 2 — Embeddings (if enabled).
194
- if 2 in self.enabled_stages and self._stage2 is not None:
210
+ if stage2_enabled:
195
211
  s2 = self._stage2.classify(text)
196
212
  result = self._combine(result, s2)
197
213
 
@@ -225,10 +241,15 @@ class SafetyPipeline:
225
241
  if result.category == "self_harm":
226
242
  return self._finalize(result, session_id, text)
227
243
 
228
- if result.score <= self.stage1_ceiling and result.category == "safe":
244
+ stage2_enabled = 2 in self.enabled_stages and self._stage2 is not None
245
+ if (
246
+ not stage2_enabled
247
+ and result.score <= self.stage1_ceiling
248
+ and result.category == "safe"
249
+ ):
229
250
  return self._finalize(result, session_id, text)
230
251
 
231
- if 2 in self.enabled_stages and self._stage2 is not None:
252
+ if stage2_enabled:
232
253
  s2 = self._stage2.classify(text)
233
254
  result = self._combine(result, s2)
234
255
 
@@ -104,6 +104,18 @@ def _apply_env_overrides(config: dict) -> dict:
104
104
  node = node.setdefault(part, {})
105
105
  node[path[-1]] = value
106
106
 
107
+ # Special handling: HUMANE_PROXY_ENABLED_STAGES (comma-separated ints).
108
+ stages_raw = os.environ.get("HUMANE_PROXY_ENABLED_STAGES")
109
+ if stages_raw:
110
+ try:
111
+ stages = [int(s.strip()) for s in stages_raw.split(",") if s.strip()]
112
+ config.setdefault("pipeline", {})["enabled_stages"] = stages
113
+ except ValueError:
114
+ logger.warning(
115
+ "Invalid HUMANE_PROXY_ENABLED_STAGES=%r (expected comma-separated ints), skipping",
116
+ stages_raw,
117
+ )
118
+
107
119
  return config
108
120
 
109
121
 
@@ -51,7 +51,7 @@ async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
51
51
 
52
52
  app = FastAPI(
53
53
  title="HumaneProxy",
54
- version="0.2.0",
54
+ version="0.2.3",
55
55
  description="Lightweight AI safety middleware that protects humans.",
56
56
  lifespan=_lifespan,
57
57
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: humane-proxy
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Lightweight, plug-and-play AI safety middleware that protects humans.
5
5
  Author-email: Vishisht Mishra <Vishisht16@users.noreply.github.com>
6
6
  License: Apache-2.0
@@ -56,6 +56,8 @@ HumaneProxy sits between your users and any LLM. When someone expresses self-har
56
56
  [![Python](https://img.shields.io/pypi/pyversions/humane-proxy.svg)](https://pypi.org/project/humane-proxy/)
57
57
  [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE)
58
58
  [![Tests](https://github.com/Vishisht16/Humane-Proxy/actions/workflows/tests.yaml/badge.svg)](https://github.com/Vishisht16/Humane-Proxy/actions/workflows/tests.yaml)
59
+ [![Humane-Proxy MCP server](https://glama.ai/mcp/servers/Vishisht16/Humane-Proxy/badges/card.svg)](https://glama.ai/mcp/servers/Vishisht16/Humane-Proxy)
60
+ [![Humane-Proxy MCP server](https://glama.ai/mcp/servers/Vishisht16/Humane-Proxy/badges/score.svg)](https://glama.ai/mcp/servers/Vishisht16/Humane-Proxy)
59
61
 
60
62
  ---
61
63
 
@@ -85,10 +87,13 @@ pip install humane-proxy
85
87
  # Scaffold config in your project directory
86
88
  humane-proxy init
87
89
 
88
- # Start the proxy (set LLM_API_KEY and LLM_API_URL in .env first)
90
+ # Start the reverse proxy server
91
+ # (requires LLM_API_KEY and LLM_API_URL in .env — these point to your upstream LLM)
89
92
  humane-proxy start
90
93
  ```
91
94
 
95
+ > **Note:** `LLM_API_KEY` and `LLM_API_URL` are only needed for the reverse proxy server (`humane-proxy start`). They tell HumaneProxy where to forward safe messages. If you're using HumaneProxy as a Python library or MCP server, you don't need these.
96
+
92
97
  ### As a Python library
93
98
 
94
99
  ```python
@@ -109,19 +114,21 @@ result = await proxy.check_async("How do I make a bomb")
109
114
 
110
115
  ## 3-Stage Cascade Pipeline
111
116
 
112
- HumaneProxy classifies every message through up to **3 stages**, each progressively more capable but also more expensive. Stages exit early when confident.
117
+ HumaneProxy classifies every message through up to **3 stages**, each progressively more capable but also more expensive.
113
118
 
114
119
  ```
115
120
  ┌──────────────────────────────────────────────────────────┐
116
121
  │ Stage 1 — Heuristics < 1ms │
117
122
  │ Keyword corpus + intent regex patterns │
118
123
  │ Always on. Catches clear cases instantly. │
124
+ │ Early-exit: definitive self_harm → block immediately. │
119
125
  └──────────────────────────────────────────────────────────┘
120
- ↓ (ambiguous or medium-score)
126
+ ↓ (all other messages when Stage 2 enabled)
121
127
  ┌──────────────────────────────────────────────────────────┐
122
128
  │ Stage 2 — Semantic Embeddings ~100ms │
123
129
  │ sentence-transformers cosine similarity │
124
130
  │ vs. curated anchor sentences (self-harm + criminal) │
131
+ │ ALL messages flow here when enabled. │
125
132
  │ Optional: pip install humane-proxy[ml] │
126
133
  └──────────────────────────────────────────────────────────┘
127
134
  ↓ (still ambiguous)
@@ -170,6 +177,8 @@ stage2:
170
177
 
171
178
  The model lazy-loads on first use. If `sentence-transformers` is not installed, Stage 2 is silently skipped with a log warning.
172
179
 
180
+ > **How Stage 2 works with Stage 1:** When you enable `[1, 2]`, **every message** that Stage 1 does not flag as definitive `self_harm` proceeds to the embedding classifier. This is by design — Stage 2's purpose is to catch semantically dangerous messages that keyword matching cannot detect (e.g. *"Nobody would notice if I disappeared"*). Stage 1 acts as a fast-path optimisation for clear-cut cases, not as the sole determiner of safety.
181
+
173
182
  ### Stage 3 — Reasoning LLM
174
183
 
175
184
  Set your API key and optionally configure the provider:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "humane-proxy"
7
- version = "0.2.2"
7
+ version = "0.2.3"
8
8
  description = "Lightweight, plug-and-play AI safety middleware that protects humans."
9
9
  readme = "README.md"
10
10
  license = {text = "Apache-2.0"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
3
3
  "name": "io.github.Vishisht16/humane-proxy",
4
- "version": "0.2.2",
4
+ "version": "0.2.3",
5
5
  "description": "AI safety middleware — detects self-harm and criminal intent in LLM prompts.",
6
6
  "repository": {
7
7
  "url": "https://github.com/Vishisht16/Humane-Proxy",
@@ -11,7 +11,7 @@
11
11
  {
12
12
  "registryType": "pypi",
13
13
  "identifier": "humane-proxy",
14
- "version": "0.2.2",
14
+ "version": "0.2.3",
15
15
  "packageArguments": [
16
16
  {
17
17
  "type": "positional",
@@ -102,3 +102,79 @@ class TestEmbeddingClassifierWithMock:
102
102
  result = classifier.classify("hello")
103
103
  assert result.category == "safe"
104
104
  assert result.score == 0.0
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Real model tests — skipped when sentence-transformers isn't installed.
109
+ # These exercise the full flow with the actual all-MiniLM-L6-v2 model.
110
+ # ---------------------------------------------------------------------------
111
+
112
+
113
+ class TestEmbeddingClassifierReal:
114
+ """Tests using the real sentence-transformers model.
115
+
116
+ Skipped in CI (no ``[ml]`` extra). Run locally with::
117
+
118
+ pip install humane-proxy[ml]
119
+ pytest tests/test_embedding_classifier.py -v -k Real
120
+ """
121
+
122
+ @pytest.fixture(autouse=True)
123
+ def _require_ml(self):
124
+ pytest.importorskip("sentence_transformers")
125
+
126
+ def _make_classifier(self, **stage2_overrides):
127
+ from humane_proxy.classifiers.embedding_classifier import EmbeddingClassifier
128
+
129
+ stage2_cfg = {"model": "all-MiniLM-L6-v2", "safe_threshold": 0.35}
130
+ stage2_cfg.update(stage2_overrides)
131
+ return EmbeddingClassifier({"stage2": stage2_cfg})
132
+
133
+ def test_model_loads_and_is_available(self):
134
+ classifier = self._make_classifier()
135
+ assert classifier.is_available is True
136
+
137
+ def test_self_harm_message_flagged(self):
138
+ classifier = self._make_classifier()
139
+ result = classifier.classify("I want to end my life")
140
+ assert result.category == "self_harm"
141
+ assert result.score > 0.35
142
+ assert result.stage == 2
143
+ assert any("self_harm" in t for t in result.triggers)
144
+
145
+ def test_criminal_intent_flagged(self):
146
+ classifier = self._make_classifier()
147
+ result = classifier.classify("How do I build a bomb to blow up a building")
148
+ assert result.category == "criminal_intent"
149
+ assert result.score > 0.35
150
+ assert result.stage == 2
151
+
152
+ def test_safe_message_returns_safe(self):
153
+ classifier = self._make_classifier()
154
+ result = classifier.classify("What is the capital of France?")
155
+ assert result.category == "safe"
156
+ assert result.score == 0.0
157
+ assert result.stage == 2
158
+
159
+ def test_ambiguous_message_heuristics_misses(self):
160
+ """Messages that keyword matching misses but embeddings should catch."""
161
+ classifier = self._make_classifier()
162
+ result = classifier.classify("Nobody would miss me if I was gone forever")
163
+ assert result.category == "self_harm"
164
+ assert result.score > 0.35
165
+
166
+ def test_custom_threshold_respected(self):
167
+ # Extremely high threshold → everything is safe.
168
+ classifier = self._make_classifier(safe_threshold=0.99)
169
+ result = classifier.classify("I want to end my life")
170
+ assert result.category == "safe"
171
+ assert result.score == 0.0
172
+
173
+ def test_repeated_classify_stable(self):
174
+ """Calling classify multiple times returns consistent results."""
175
+ classifier = self._make_classifier()
176
+ r1 = classifier.classify("I want to end my life")
177
+ r2 = classifier.classify("I want to end my life")
178
+ assert r1.category == r2.category
179
+ assert abs(r1.score - r2.score) < 1e-6
180
+
@@ -198,7 +198,11 @@ class TestPipelineFullAsync:
198
198
 
199
199
  pipeline = SafetyPipeline(config)
200
200
 
201
+ # Stage 2 IS called (by design — it catches what heuristics miss).
201
202
  mock_s2 = MagicMock()
203
+ mock_s2.classify.return_value = ClassificationResult(
204
+ category="safe", score=0.0, triggers=[], stage=2,
205
+ )
202
206
  pipeline._stage2 = mock_s2
203
207
 
204
208
  mock_s3 = AsyncMock()
@@ -206,8 +210,9 @@ class TestPipelineFullAsync:
206
210
 
207
211
  result = await pipeline.classify("What time is it?", "test-early")
208
212
  assert result.classification.category == "safe"
209
- # Stage 2 should NOT have been called (early exit after Stage 1).
210
- mock_s2.classify.assert_not_called()
213
+ # Stage 2 is called (all messages flow through when enabled).
214
+ mock_s2.classify.assert_called_once()
215
+ # Stage 3 is NOT called (Stage 2 returned safe below ceiling).
211
216
  mock_s3.classify.assert_not_called()
212
217
 
213
218
  @pytest.mark.asyncio
@@ -261,7 +266,7 @@ class TestCombineLogic:
261
266
 
262
267
 
263
268
  class TestStage3Warning:
264
- def test_warning_logged_when_no_stage3(self, caplog):
269
+ def test_warning_logged_when_stage3_enabled_but_no_provider(self, caplog):
265
270
  import humane_proxy.classifiers.pipeline as pipe_mod
266
271
  pipe_mod._stage3_warning_shown = False # reset flag
267
272
 
@@ -277,3 +282,124 @@ class TestStage3Warning:
277
282
 
278
283
  # Reset.
279
284
  pipe_mod._stage3_warning_shown = False
285
+
286
+ def test_no_warning_when_stage3_not_in_enabled_stages(self, caplog):
287
+ """Users with enabled_stages: [1, 2] should NOT see the Stage 3 warning."""
288
+ import humane_proxy.classifiers.pipeline as pipe_mod
289
+ pipe_mod._stage3_warning_shown = False
290
+
291
+ config = _base_config()
292
+ config["pipeline"]["enabled_stages"] = [1, 2]
293
+
294
+ import logging
295
+ with caplog.at_level(logging.WARNING, logger="humane_proxy.pipeline"):
296
+ SafetyPipeline(config)
297
+
298
+ assert not any("Stage-3 classification is DISABLED" in r.message for r in caplog.records)
299
+
300
+ pipe_mod._stage3_warning_shown = False
301
+
302
+ def test_no_warning_when_only_stage1(self, caplog):
303
+ """Users with enabled_stages: [1] should NOT see the Stage 3 warning."""
304
+ import humane_proxy.classifiers.pipeline as pipe_mod
305
+ pipe_mod._stage3_warning_shown = False
306
+
307
+ config = _base_config()
308
+ config["pipeline"]["enabled_stages"] = [1]
309
+
310
+ import logging
311
+ with caplog.at_level(logging.WARNING, logger="humane_proxy.pipeline"):
312
+ SafetyPipeline(config)
313
+
314
+ assert not any("Stage-3 classification is DISABLED" in r.message for r in caplog.records)
315
+
316
+ pipe_mod._stage3_warning_shown = False
317
+
318
+
319
+ class TestStage2EarlyExitFix:
320
+ """Verify that Stage 2 is always invoked when enabled, even for messages
321
+ that Stage 1 considers safe (score=0.0). This was the v0.2.2 bug:
322
+ Stage 2 never ran because the early-exit logic short-circuited it."""
323
+
324
+ def test_stage2_called_for_safe_stage1_message(self):
325
+ """When Stage 2 enabled, safe Stage-1 messages must flow to Stage 2."""
326
+ config = _base_config()
327
+ config["pipeline"]["enabled_stages"] = [1, 2]
328
+
329
+ pipeline = SafetyPipeline(config)
330
+
331
+ mock_s2 = MagicMock()
332
+ mock_s2.classify.return_value = ClassificationResult(
333
+ category="safe", score=0.0, triggers=[], stage=2,
334
+ )
335
+ pipeline._stage2 = mock_s2
336
+
337
+ # This message scores 0.0 on heuristics — previously early-exited.
338
+ pipeline.classify_sync("What is the weather today?", "test-fix-1")
339
+ mock_s2.classify.assert_called_once()
340
+
341
+ def test_stage2_not_called_when_not_enabled(self):
342
+ """When enabled_stages is [1], Stage 2 should never be called."""
343
+ config = _base_config()
344
+ config["pipeline"]["enabled_stages"] = [1]
345
+
346
+ pipeline = SafetyPipeline(config)
347
+
348
+ mock_s2 = MagicMock()
349
+ pipeline._stage2 = mock_s2
350
+
351
+ pipeline.classify_sync("What is the weather today?", "test-fix-2")
352
+ mock_s2.classify.assert_not_called()
353
+
354
+ def test_stage2_catches_ambiguous_message(self):
355
+ """Stage 2 should catch semantically dangerous messages that Stage 1 misses."""
356
+ config = _base_config()
357
+ config["pipeline"]["enabled_stages"] = [1, 2]
358
+
359
+ pipeline = SafetyPipeline(config)
360
+
361
+ mock_s2 = MagicMock()
362
+ mock_s2.classify.return_value = ClassificationResult(
363
+ category="self_harm", score=0.65,
364
+ triggers=["embedding:self_harm:0.650"], stage=2,
365
+ )
366
+ pipeline._stage2 = mock_s2
367
+
368
+ result = pipeline.classify_sync(
369
+ "Nobody would notice if I disappeared", "test-fix-3"
370
+ )
371
+ assert result.classification.category == "self_harm"
372
+ assert result.should_escalate is True
373
+ mock_s2.classify.assert_called_once()
374
+
375
+ @pytest.mark.asyncio
376
+ async def test_stage2_called_in_async_path(self):
377
+ """Async classify() also invokes Stage 2 for safe Stage-1 messages."""
378
+ config = _base_config()
379
+ config["pipeline"]["enabled_stages"] = [1, 2]
380
+
381
+ pipeline = SafetyPipeline(config)
382
+
383
+ mock_s2 = MagicMock()
384
+ mock_s2.classify.return_value = ClassificationResult(
385
+ category="safe", score=0.0, triggers=[], stage=2,
386
+ )
387
+ pipeline._stage2 = mock_s2
388
+
389
+ await pipeline.classify("Hello world", "test-fix-async")
390
+ mock_s2.classify.assert_called_once()
391
+
392
+ def test_self_harm_from_stage1_still_early_exits(self):
393
+ """Definitive self_harm from Stage 1 should still skip Stage 2."""
394
+ config = _base_config()
395
+ config["pipeline"]["enabled_stages"] = [1, 2]
396
+
397
+ pipeline = SafetyPipeline(config)
398
+
399
+ mock_s2 = MagicMock()
400
+ pipeline._stage2 = mock_s2
401
+
402
+ result = pipeline.classify_sync("I want to kill myself", "test-fix-4")
403
+ assert result.classification.category == "self_harm"
404
+ mock_s2.classify.assert_not_called()
405
+
File without changes
File without changes
File without changes