Skip to content

Commit 0c7e281

Browse files
committed
fix: Stage 2 embedding classifier never ran, Stage 3 warning shown incorrectly — v0.2.3
Pipeline: - Fix early-exit logic that prevented Stage 2 from ever running. When Stage 2 is enabled, ALL messages now flow to the embedding classifier (except definitive self_harm from Stage 1). Previously, score=0.0 from heuristics triggered early-exit before Stage 2 had a chance — defeating its entire purpose. - Fix Stage 3 warning only shown when 3 is in enabled_stages. Users with [1] or [1,2] no longer see the misleading warning. Config: - Wire up HUMANE_PROXY_ENABLED_STAGES env var (was documented but not implemented). Accepts comma-separated ints e.g. '1,2'. Tests: - Add TestEmbeddingClassifierReal: 7 tests using the actual all-MiniLM-L6-v2 model (guarded by importorskip, skipped in CI). - Add TestStage2EarlyExitFix: 5 regression tests for the pipeline fix. - Add 2 Stage 3 warning gating tests. - Update test_early_exit_clear_safe for new behaviour. - Total: 186 → 200 tests passing. Docs: - README: clarify Stage 2 behaviour (all messages flow through when enabled), add LLM_API_KEY/LLM_API_URL note (only needed for proxy server), add Glama MCP badges. - CHANGELOG: full v0.2.3 entry. Meta: - Add glama.json for Glama MCP directory listing. - Bump version to 0.2.3 (pyproject.toml, __init__.py, server.json, interceptor FastAPI app).
1 parent e7a5044 commit 0c7e281

11 files changed

Lines changed: 289 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,30 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
66

77
---
88

9+
## [0.2.3] — 2026-04-01
10+
11+
### Fixed
12+
13+
- **Stage 2 never ran:** The pipeline's early-exit logic was too aggressive — when Stage 1 scored a message as safe (score 0.0), it would early-exit before the embedding classifier had a chance to evaluate it. This defeated Stage 2's entire purpose: catching *semantically* dangerous messages that keyword matching misses. Now, when Stage 2 is enabled, all messages that Stage 1 does not flag as `self_harm` proceed to the embedding classifier.
14+
- **Stage 3 warning shown incorrectly:** Users with `enabled_stages: [1]` or `[1, 2]` saw a Stage 3 "DISABLED" warning even though they never configured Stage 3. The warning now only appears when `3` is in `enabled_stages` but no API key / provider is available.
15+
- **`HUMANE_PROXY_ENABLED_STAGES` env var not wired up:** Documented in README but not implemented in the config loader. Now accepts comma-separated ints (e.g. `"1,2"`).
16+
- **FastAPI app version** pinned to `0.2.0` — updated to `0.2.3`.
17+
18+
### Added
19+
20+
- **`glama.json`** metadata for Glama MCP directory listing.
21+
- **Real embedding model tests:** New `TestEmbeddingClassifierReal` test class that exercises the full `all-MiniLM-L6-v2` classify flow (guarded by `pytest.importorskip`, auto-skipped in CI).
22+
- **Pipeline early-exit regression tests:** `TestStage2EarlyExitFix` class verifying that Stage 2 is always invoked when enabled, even for messages heuristics considers safe.
23+
- **Stage 3 warning tests:** Tests that verify the warning is only shown when Stage 3 is in `enabled_stages`.
24+
- **Glama badges** in README for MCP server card and quality score.
25+
26+
### Changed
27+
28+
- README updated to clarify Stage 2 behaviour: when enabled, all messages flow through the embedding classifier. Stage 1 heuristics becomes an early-exit optimisation for clear self-harm only, not a safety determiner.
29+
- README updated to clarify `LLM_API_KEY` / `LLM_API_URL` are only needed for the reverse proxy server, not for the library API or MCP server.
30+
31+
---
32+
933
## [0.2.2] — 2026-03-31
1034

1135
### Added

README.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ HumaneProxy sits between your users and any LLM. When someone expresses self-har
1010
[![Python](https://img.shields.io/pypi/pyversions/humane-proxy.svg)](https://pypi.org/project/humane-proxy/)
1111
[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE)
1212
[![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)
1315

1416
---
1517

@@ -39,10 +41,13 @@ pip install humane-proxy
3941
# Scaffold config in your project directory
4042
humane-proxy init
4143

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)
4346
humane-proxy start
4447
```
4548

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+
4651
### As a Python library
4752

4853
```python
@@ -63,19 +68,21 @@ result = await proxy.check_async("How do I make a bomb")
6368

6469
## 3-Stage Cascade Pipeline
6570

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.
6772

6873
```
6974
┌──────────────────────────────────────────────────────────┐
7075
│ Stage 1 — Heuristics < 1ms │
7176
│ Keyword corpus + intent regex patterns │
7277
│ Always on. Catches clear cases instantly. │
78+
│ Early-exit: definitive self_harm → block immediately. │
7379
└──────────────────────────────────────────────────────────┘
74-
↓ (ambiguous or medium-score)
80+
↓ (all other messages when Stage 2 enabled)
7581
┌──────────────────────────────────────────────────────────┐
7682
│ Stage 2 — Semantic Embeddings ~100ms │
7783
│ sentence-transformers cosine similarity │
7884
│ vs. curated anchor sentences (self-harm + criminal) │
85+
│ ALL messages flow here when enabled. │
7986
│ Optional: pip install humane-proxy[ml] │
8087
└──────────────────────────────────────────────────────────┘
8188
↓ (still ambiguous)
@@ -124,6 +131,8 @@ stage2:
124131

125132
The model lazy-loads on first use. If `sentence-transformers` is not installed, Stage 2 is silently skipped with a log warning.
126133

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+
127136
### Stage 3 — Reasoning LLM
128137

129138
Set your API key and optionally configure the provider:

glama.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"$schema": "https://glama.ai/mcp/schemas/server.json",
3+
"maintainers": ["Vishisht16"]
4+
}

humane_proxy/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
from __future__ import annotations
1818

19-
__version__ = "0.2.2"
19+
__version__ = "0.2.3"
2020

2121
# ---------------------------------------------------------------------------
2222
# Legacy API — keep backward compatibility with existing modules that call

humane_proxy/classifiers/pipeline.py

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,15 @@ def _init_stage3(self) -> None:
149149
self._stage3 = None
150150

151151
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+
"""
153158
global _stage3_warning_shown
159+
if 3 not in self.enabled_stages:
160+
return # User didn't ask for Stage 3 — no warning needed.
154161
if self._stage3 is not None or _stage3_warning_shown:
155162
return
156163
_stage3_warning_shown = True
@@ -186,12 +193,21 @@ async def classify(
186193
if result.category == "self_harm":
187194
return self._finalize(result, session_id, text)
188195

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+
):
191207
return self._finalize(result, session_id, text)
192208

193209
# Stage 2 — Embeddings (if enabled).
194-
if 2 in self.enabled_stages and self._stage2 is not None:
210+
if stage2_enabled:
195211
s2 = self._stage2.classify(text)
196212
result = self._combine(result, s2)
197213

@@ -225,10 +241,15 @@ def classify_sync(
225241
if result.category == "self_harm":
226242
return self._finalize(result, session_id, text)
227243

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+
):
229250
return self._finalize(result, session_id, text)
230251

231-
if 2 in self.enabled_stages and self._stage2 is not None:
252+
if stage2_enabled:
232253
s2 = self._stage2.classify(text)
233254
result = self._combine(result, s2)
234255

humane_proxy/config.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,18 @@ def _apply_env_overrides(config: dict) -> dict:
104104
node = node.setdefault(part, {})
105105
node[path[-1]] = value
106106

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+
107119
return config
108120

109121

humane_proxy/middleware/interceptor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
5151

5252
app = FastAPI(
5353
title="HumaneProxy",
54-
version="0.2.0",
54+
version="0.2.3",
5555
description="Lightweight AI safety middleware that protects humans.",
5656
lifespan=_lifespan,
5757
)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "humane-proxy"
7-
version = "0.2.2"
7+
version = "0.2.3"
88
description = "Lightweight, plug-and-play AI safety middleware that protects humans."
99
readme = "README.md"
1010
license = {text = "Apache-2.0"}

server.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
33
"name": "io.github.Vishisht16/humane-proxy",
4-
"version": "0.2.2",
4+
"version": "0.2.3",
55
"description": "AI safety middleware — detects self-harm and criminal intent in LLM prompts.",
66
"repository": {
77
"url": "https://github.com/Vishisht16/Humane-Proxy",
@@ -11,7 +11,7 @@
1111
{
1212
"registryType": "pypi",
1313
"identifier": "humane-proxy",
14-
"version": "0.2.2",
14+
"version": "0.2.3",
1515
"packageArguments": [
1616
{
1717
"type": "positional",

tests/test_embedding_classifier.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,79 @@ def test_classify_returns_safe_below_threshold(self):
102102
result = classifier.classify("hello")
103103
assert result.category == "safe"
104104
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+

0 commit comments

Comments
 (0)