humane-proxy 0.3.0__tar.gz → 0.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {humane_proxy-0.3.0/humane_proxy.egg-info → humane_proxy-0.3.1}/PKG-INFO +64 -1
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/README.md +63 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/__init__.py +1 -1
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/cli.py +1 -1
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/config.py +1 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/config.yaml +12 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/middleware/interceptor.py +1 -1
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/risk/trajectory.py +63 -11
- {humane_proxy-0.3.0 → humane_proxy-0.3.1/humane_proxy.egg-info}/PKG-INFO +64 -1
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/pyproject.toml +1 -1
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/server.json +2 -2
- humane_proxy-0.3.1/tests/test_trajectory.py +180 -0
- humane_proxy-0.3.0/tests/test_trajectory.py +0 -108
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/LICENSE +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/NOTICE +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/api/__init__.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/api/admin.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/classifiers/__init__.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/classifiers/embedding_classifier.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/classifiers/heuristics.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/classifiers/models.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/classifiers/pipeline.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/classifiers/stage3/__init__.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/classifiers/stage3/base.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/classifiers/stage3/llamaguard.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/classifiers/stage3/openai_chat.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/classifiers/stage3/openai_moderation.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/escalation/__init__.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/escalation/local_db.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/escalation/router.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/escalation/webhooks.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/integrations/__init__.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/integrations/autogen.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/integrations/crewai.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/integrations/langchain.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/integrations/llamaindex.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/mcp_server.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/middleware/__init__.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/risk/__init__.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/storage/__init__.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/storage/base.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/storage/factory.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/storage/postgres.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/storage/redis.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/storage/sqlite.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy.egg-info/SOURCES.txt +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy.egg-info/dependency_links.txt +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy.egg-info/entry_points.txt +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy.egg-info/requires.txt +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy.egg-info/top_level.txt +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/setup.cfg +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/tests/test_admin_api.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/tests/test_care_response.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/tests/test_cli.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/tests/test_config.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/tests/test_embedding_classifier.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/tests/test_embedding_singleton.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/tests/test_enhanced_webhooks.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/tests/test_heuristics.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/tests/test_integrations_smoke.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/tests/test_interceptor.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/tests/test_local_db.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/tests/test_models.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/tests/test_pipeline.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/tests/test_router.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/tests/test_self_harm_threshold.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/tests/test_stage3.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/tests/test_storage_backends.py +0 -0
- {humane_proxy-0.3.0 → humane_proxy-0.3.1}/tests/test_webhooks.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: humane-proxy
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
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
|
|
@@ -186,6 +186,8 @@ stage2:
|
|
|
186
186
|
safe_threshold: 0.35 # cosine similarity below this → safe
|
|
187
187
|
```
|
|
188
188
|
|
|
189
|
+
> **Multilingual Support:** If your users converse in non-English languages (Roman Hindi, Spanish, Arabic, etc.), change the `model` in your configuration to `"paraphrase-multilingual-MiniLM-L12-v2"`. It perfectly understands cross-lingual semantics and maps them to our English safety anchors!
|
|
190
|
+
|
|
189
191
|
The model lazy-loads on first use. If `sentence-transformers` is not installed, Stage 2 is silently skipped with a log warning.
|
|
190
192
|
|
|
191
193
|
> **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.
|
|
@@ -273,6 +275,67 @@ The injected system prompt instructs the LLM to respond with empathy, validate f
|
|
|
273
275
|
|
|
274
276
|
---
|
|
275
277
|
|
|
278
|
+
## Risk Trajectory & Time-Decay
|
|
279
|
+
|
|
280
|
+
HumaneProxy tracks a **rolling window** of the last 5 risk scores per session.
|
|
281
|
+
When a new message arrives, its score is compared against the
|
|
282
|
+
**decay-weighted mean** of that window:
|
|
283
|
+
|
|
284
|
+
```
|
|
285
|
+
delta = current_score − weighted_mean(last N scores)
|
|
286
|
+
spike = delta > 0.35 (configurable via spike_delta)
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
If a spike is detected, a **boost penalty** (`+0.25`) is added to the
|
|
290
|
+
current score to push it closer to escalation.
|
|
291
|
+
|
|
292
|
+
### Exponential Time-Decay
|
|
293
|
+
|
|
294
|
+
Historical scores are weighted using the formula:
|
|
295
|
+
|
|
296
|
+
$$w_i = e^{-\lambda \, \Delta t_i}$$
|
|
297
|
+
|
|
298
|
+
where **λ = ln(2) / half-life** and **Δt** is the age of each score in
|
|
299
|
+
seconds. This means:
|
|
300
|
+
|
|
301
|
+
| Time elapsed | Weight (24 h half-life) | Meaning |
|
|
302
|
+
|---|---|---|
|
|
303
|
+
| 5 minutes | 99.8 % | Near-full weight — live conversation |
|
|
304
|
+
| 6 hours | 84 % | Still highly relevant |
|
|
305
|
+
| 24 hours | 50 % | Half weight — yesterday's scores |
|
|
306
|
+
| 48 hours | 25 % | Faded — two days ago |
|
|
307
|
+
| 72 hours | 12.5 % | Nearly forgotten |
|
|
308
|
+
|
|
309
|
+
**Why this matters:** Without decay, a user who had a tough conversation
|
|
310
|
+
on Monday would carry that elevated baseline into Thursday—unfairly
|
|
311
|
+
triggering spikes on innocuous messages. With a 24-hour half-life,
|
|
312
|
+
old scores gracefully fade while rapid within-session escalation is
|
|
313
|
+
still caught instantly.
|
|
314
|
+
|
|
315
|
+
### Configuration
|
|
316
|
+
|
|
317
|
+
```yaml
|
|
318
|
+
trajectory:
|
|
319
|
+
window_size: 5 # messages in rolling window
|
|
320
|
+
spike_delta: 0.35 # delta threshold for spike detection
|
|
321
|
+
|
|
322
|
+
# Half-life in hours. After this period, a historical score
|
|
323
|
+
# carries only 50 % of its original weight.
|
|
324
|
+
# 24 → balanced forgiveness + familiarity (default)
|
|
325
|
+
# 6 → aggressive decay, only very recent history matters
|
|
326
|
+
# 72 → gentle decay, multi-day memory
|
|
327
|
+
# 0 → disable decay (plain unweighted mean)
|
|
328
|
+
decay_half_life_hours: 24.0
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
Or via environment variable:
|
|
332
|
+
|
|
333
|
+
```bash
|
|
334
|
+
export HUMANE_PROXY_DECAY_HALF_LIFE=12 # 12-hour half-life
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
---
|
|
338
|
+
|
|
276
339
|
## Alert Webhooks
|
|
277
340
|
|
|
278
341
|
Configure in `humane_proxy.yaml`:
|
|
@@ -129,6 +129,8 @@ stage2:
|
|
|
129
129
|
safe_threshold: 0.35 # cosine similarity below this → safe
|
|
130
130
|
```
|
|
131
131
|
|
|
132
|
+
> **Multilingual Support:** If your users converse in non-English languages (Roman Hindi, Spanish, Arabic, etc.), change the `model` in your configuration to `"paraphrase-multilingual-MiniLM-L12-v2"`. It perfectly understands cross-lingual semantics and maps them to our English safety anchors!
|
|
133
|
+
|
|
132
134
|
The model lazy-loads on first use. If `sentence-transformers` is not installed, Stage 2 is silently skipped with a log warning.
|
|
133
135
|
|
|
134
136
|
> **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.
|
|
@@ -216,6 +218,67 @@ The injected system prompt instructs the LLM to respond with empathy, validate f
|
|
|
216
218
|
|
|
217
219
|
---
|
|
218
220
|
|
|
221
|
+
## Risk Trajectory & Time-Decay
|
|
222
|
+
|
|
223
|
+
HumaneProxy tracks a **rolling window** of the last 5 risk scores per session.
|
|
224
|
+
When a new message arrives, its score is compared against the
|
|
225
|
+
**decay-weighted mean** of that window:
|
|
226
|
+
|
|
227
|
+
```
|
|
228
|
+
delta = current_score − weighted_mean(last N scores)
|
|
229
|
+
spike = delta > 0.35 (configurable via spike_delta)
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
If a spike is detected, a **boost penalty** (`+0.25`) is added to the
|
|
233
|
+
current score to push it closer to escalation.
|
|
234
|
+
|
|
235
|
+
### Exponential Time-Decay
|
|
236
|
+
|
|
237
|
+
Historical scores are weighted using the formula:
|
|
238
|
+
|
|
239
|
+
$$w_i = e^{-\lambda \, \Delta t_i}$$
|
|
240
|
+
|
|
241
|
+
where **λ = ln(2) / half-life** and **Δt** is the age of each score in
|
|
242
|
+
seconds. This means:
|
|
243
|
+
|
|
244
|
+
| Time elapsed | Weight (24 h half-life) | Meaning |
|
|
245
|
+
|---|---|---|
|
|
246
|
+
| 5 minutes | 99.8 % | Near-full weight — live conversation |
|
|
247
|
+
| 6 hours | 84 % | Still highly relevant |
|
|
248
|
+
| 24 hours | 50 % | Half weight — yesterday's scores |
|
|
249
|
+
| 48 hours | 25 % | Faded — two days ago |
|
|
250
|
+
| 72 hours | 12.5 % | Nearly forgotten |
|
|
251
|
+
|
|
252
|
+
**Why this matters:** Without decay, a user who had a tough conversation
|
|
253
|
+
on Monday would carry that elevated baseline into Thursday—unfairly
|
|
254
|
+
triggering spikes on innocuous messages. With a 24-hour half-life,
|
|
255
|
+
old scores gracefully fade while rapid within-session escalation is
|
|
256
|
+
still caught instantly.
|
|
257
|
+
|
|
258
|
+
### Configuration
|
|
259
|
+
|
|
260
|
+
```yaml
|
|
261
|
+
trajectory:
|
|
262
|
+
window_size: 5 # messages in rolling window
|
|
263
|
+
spike_delta: 0.35 # delta threshold for spike detection
|
|
264
|
+
|
|
265
|
+
# Half-life in hours. After this period, a historical score
|
|
266
|
+
# carries only 50 % of its original weight.
|
|
267
|
+
# 24 → balanced forgiveness + familiarity (default)
|
|
268
|
+
# 6 → aggressive decay, only very recent history matters
|
|
269
|
+
# 72 → gentle decay, multi-day memory
|
|
270
|
+
# 0 → disable decay (plain unweighted mean)
|
|
271
|
+
decay_half_life_hours: 24.0
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Or via environment variable:
|
|
275
|
+
|
|
276
|
+
```bash
|
|
277
|
+
export HUMANE_PROXY_DECAY_HALF_LIFE=12 # 12-hour half-life
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
219
282
|
## Alert Webhooks
|
|
220
283
|
|
|
221
284
|
Configure in `humane_proxy.yaml`:
|
|
@@ -346,7 +346,7 @@ def mcp_serve(transport: str, host: str, port: int) -> None:
|
|
|
346
346
|
serve_http(host=host, port=port)
|
|
347
347
|
else:
|
|
348
348
|
from humane_proxy.mcp_server import serve
|
|
349
|
-
click.echo(" 🤖 Starting HumaneProxy MCP server (stdio)...")
|
|
349
|
+
click.echo(" 🤖 Starting HumaneProxy MCP server (stdio)...", err=True)
|
|
350
350
|
serve()
|
|
351
351
|
except RuntimeError as exc:
|
|
352
352
|
click.echo(f"\n ❌ {exc}\n", err=True)
|
|
@@ -88,6 +88,7 @@ def _apply_env_overrides(config: dict) -> dict:
|
|
|
88
88
|
"HUMANE_PROXY_STORAGE_BACKEND": (["storage", "backend"], str),
|
|
89
89
|
"HUMANE_PROXY_REDIS_URL": (["storage", "redis", "url"], str),
|
|
90
90
|
"HUMANE_PROXY_POSTGRES_DSN": (["storage", "postgres", "dsn"], str),
|
|
91
|
+
"HUMANE_PROXY_DECAY_HALF_LIFE": (["trajectory", "decay_half_life_hours"], float),
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
for env_key, (path, cast) in _ENV_MAP.items():
|
|
@@ -97,6 +97,18 @@ trajectory:
|
|
|
97
97
|
# Delta threshold: if current_score - mean(last N) > spike_delta, flag it.
|
|
98
98
|
spike_delta: 0.35
|
|
99
99
|
|
|
100
|
+
# Exponential time-decay half-life (hours).
|
|
101
|
+
# After this many hours, a historical score carries only 50 % of its
|
|
102
|
+
# original weight in the rolling baseline. This prevents stale data
|
|
103
|
+
# from penalising returning users while still catching rapid escalation.
|
|
104
|
+
#
|
|
105
|
+
# Examples:
|
|
106
|
+
# 24 → a score from yesterday has 50 % weight (default)
|
|
107
|
+
# 6 → aggressive decay; only very recent history matters
|
|
108
|
+
# 72 → gentle decay; multi-day memory
|
|
109
|
+
# 0 → disable decay entirely (plain unweighted mean)
|
|
110
|
+
decay_half_life_hours: 24.0
|
|
111
|
+
|
|
100
112
|
escalation:
|
|
101
113
|
# Per-session rate limiting for escalation logging.
|
|
102
114
|
rate_limit_max: 3
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
"""RiskTracker — trajectory-based spike detection and trend analysis.
|
|
1
|
+
"""RiskTracker — trajectory-based spike detection and trend analysis.
|
|
2
|
+
|
|
3
|
+
Supports **exponential time-decay** so that stale scores from hours or
|
|
4
|
+
days ago naturally fade toward zero, giving returning users a fair
|
|
5
|
+
baseline while still catching rapid within-session escalation.
|
|
6
|
+
"""
|
|
2
7
|
|
|
3
8
|
from __future__ import annotations
|
|
4
9
|
|
|
10
|
+
import math
|
|
11
|
+
import time
|
|
5
12
|
from collections import deque
|
|
6
|
-
from statistics import mean
|
|
7
13
|
|
|
8
14
|
from humane_proxy import load_config
|
|
9
15
|
from humane_proxy.classifiers.models import TrajectoryResult
|
|
@@ -15,13 +21,26 @@ _CFG: dict = load_config().get("trajectory", {})
|
|
|
15
21
|
_WINDOW_SIZE: int = _CFG.get("window_size", 5)
|
|
16
22
|
_SPIKE_DELTA: float = _CFG.get("spike_delta", 0.35)
|
|
17
23
|
|
|
24
|
+
# Decay half-life in hours. After this many hours a historical score
|
|
25
|
+
# contributes only 50 % of its original weight to the rolling baseline.
|
|
26
|
+
# Set to 0 or negative to disable decay entirely.
|
|
27
|
+
_DECAY_HALF_LIFE_HOURS: float = _CFG.get("decay_half_life_hours", 24.0)
|
|
28
|
+
|
|
29
|
+
# Precompute lambda: λ = ln(2) / half_life.
|
|
30
|
+
_DECAY_LAMBDA: float = (
|
|
31
|
+
math.log(2) / (_DECAY_HALF_LIFE_HOURS * 3600)
|
|
32
|
+
if _DECAY_HALF_LIFE_HOURS > 0
|
|
33
|
+
else 0.0
|
|
34
|
+
)
|
|
35
|
+
|
|
18
36
|
# Maximum distinct sessions to track before eviction (memory-leak prevention).
|
|
19
37
|
_MAX_SESSIONS: int = 1000
|
|
20
38
|
|
|
21
39
|
# ---------------------------------------------------------------------------
|
|
22
40
|
# In-memory session stores
|
|
23
41
|
# ---------------------------------------------------------------------------
|
|
24
|
-
|
|
42
|
+
# Each entry is (score, timestamp_seconds).
|
|
43
|
+
session_history: dict[str, deque[tuple[float, float]]] = {}
|
|
25
44
|
_category_history: dict[str, deque[str]] = {}
|
|
26
45
|
|
|
27
46
|
|
|
@@ -38,12 +57,43 @@ def _evict_oldest_sessions() -> None:
|
|
|
38
57
|
_category_history.pop(oldest_key, None)
|
|
39
58
|
|
|
40
59
|
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# Decay-weighted mean
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
def _weighted_mean(history: deque[tuple[float, float]], now: float) -> float:
|
|
65
|
+
"""Compute the exponentially time-decayed weighted mean of *history*.
|
|
66
|
+
|
|
67
|
+
Each entry ``(score, ts)`` is weighted by ``e^{-λ(now-ts)}``.
|
|
68
|
+
When decay is disabled (λ = 0), this collapses to a plain mean.
|
|
69
|
+
|
|
70
|
+
Returns 0.0 when *history* is empty (should never happen in practice
|
|
71
|
+
because callers gate on ``len(history) == 0`` first).
|
|
72
|
+
"""
|
|
73
|
+
if _DECAY_LAMBDA == 0.0:
|
|
74
|
+
# Fast path: decay disabled — plain mean.
|
|
75
|
+
return sum(s for s, _ in history) / len(history) if history else 0.0
|
|
76
|
+
|
|
77
|
+
total_weight = 0.0
|
|
78
|
+
weighted_sum = 0.0
|
|
79
|
+
for score, ts in history:
|
|
80
|
+
dt = now - ts # seconds elapsed
|
|
81
|
+
w = math.exp(-_DECAY_LAMBDA * dt)
|
|
82
|
+
weighted_sum += score * w
|
|
83
|
+
total_weight += w
|
|
84
|
+
|
|
85
|
+
return weighted_sum / total_weight if total_weight > 0 else 0.0
|
|
86
|
+
|
|
87
|
+
|
|
41
88
|
def detect_spike(session_id: str, current_score: float) -> bool:
|
|
42
89
|
"""Return ``True`` if the current score spikes above the recent average.
|
|
43
90
|
|
|
44
91
|
Math
|
|
45
92
|
----
|
|
46
|
-
``delta = current_score -
|
|
93
|
+
``delta = current_score - weighted_mean(last N scores)``
|
|
94
|
+
|
|
95
|
+
Historical scores are weighted by ``e^{-λ Δt}`` (exponential
|
|
96
|
+
time-decay with a configurable half-life, default 24 h).
|
|
47
97
|
|
|
48
98
|
If ``delta > _SPIKE_DELTA`` (default **0.35**), the interaction is
|
|
49
99
|
considered a behavioural spike.
|
|
@@ -60,6 +110,8 @@ def detect_spike(session_id: str, current_score: float) -> bool:
|
|
|
60
110
|
bool
|
|
61
111
|
Whether a spike was detected.
|
|
62
112
|
"""
|
|
113
|
+
now = time.time()
|
|
114
|
+
|
|
63
115
|
# --- memory-leak guard ---
|
|
64
116
|
if len(session_history) > _MAX_SESSIONS and session_id not in session_history:
|
|
65
117
|
_evict_oldest_sessions()
|
|
@@ -72,15 +124,15 @@ def detect_spike(session_id: str, current_score: float) -> bool:
|
|
|
72
124
|
|
|
73
125
|
# Not enough history to calculate a meaningful delta yet.
|
|
74
126
|
if len(history) == 0:
|
|
75
|
-
history.append(current_score)
|
|
127
|
+
history.append((current_score, now))
|
|
76
128
|
return False
|
|
77
129
|
|
|
78
|
-
avg =
|
|
130
|
+
avg = _weighted_mean(history, now)
|
|
79
131
|
delta = current_score - avg
|
|
80
132
|
|
|
81
133
|
# Always record *after* computing the delta so the current score
|
|
82
134
|
# doesn't influence the baseline it's compared against.
|
|
83
|
-
history.append(current_score)
|
|
135
|
+
history.append((current_score, now))
|
|
84
136
|
|
|
85
137
|
return delta > _SPIKE_DELTA
|
|
86
138
|
|
|
@@ -123,16 +175,16 @@ def analyze(
|
|
|
123
175
|
_category_history[session_id] = deque(maxlen=_WINDOW_SIZE)
|
|
124
176
|
_category_history[session_id].append(category)
|
|
125
177
|
|
|
126
|
-
# Get current window.
|
|
178
|
+
# Get current window (extract raw scores for the public API).
|
|
127
179
|
history = session_history.get(session_id, deque())
|
|
128
|
-
scores =
|
|
180
|
+
scores = [s for s, _ in history]
|
|
129
181
|
|
|
130
182
|
# Trend detection: compare first half vs second half of the window.
|
|
131
183
|
trend = "stable"
|
|
132
184
|
if len(scores) >= 4:
|
|
133
185
|
mid = len(scores) // 2
|
|
134
|
-
first_half_avg =
|
|
135
|
-
second_half_avg =
|
|
186
|
+
first_half_avg = sum(scores[:mid]) / mid
|
|
187
|
+
second_half_avg = sum(scores[mid:]) / (len(scores) - mid)
|
|
136
188
|
trend_delta = second_half_avg - first_half_avg
|
|
137
189
|
if trend_delta > 0.15:
|
|
138
190
|
trend = "escalating"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: humane-proxy
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
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
|
|
@@ -186,6 +186,8 @@ stage2:
|
|
|
186
186
|
safe_threshold: 0.35 # cosine similarity below this → safe
|
|
187
187
|
```
|
|
188
188
|
|
|
189
|
+
> **Multilingual Support:** If your users converse in non-English languages (Roman Hindi, Spanish, Arabic, etc.), change the `model` in your configuration to `"paraphrase-multilingual-MiniLM-L12-v2"`. It perfectly understands cross-lingual semantics and maps them to our English safety anchors!
|
|
190
|
+
|
|
189
191
|
The model lazy-loads on first use. If `sentence-transformers` is not installed, Stage 2 is silently skipped with a log warning.
|
|
190
192
|
|
|
191
193
|
> **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.
|
|
@@ -273,6 +275,67 @@ The injected system prompt instructs the LLM to respond with empathy, validate f
|
|
|
273
275
|
|
|
274
276
|
---
|
|
275
277
|
|
|
278
|
+
## Risk Trajectory & Time-Decay
|
|
279
|
+
|
|
280
|
+
HumaneProxy tracks a **rolling window** of the last 5 risk scores per session.
|
|
281
|
+
When a new message arrives, its score is compared against the
|
|
282
|
+
**decay-weighted mean** of that window:
|
|
283
|
+
|
|
284
|
+
```
|
|
285
|
+
delta = current_score − weighted_mean(last N scores)
|
|
286
|
+
spike = delta > 0.35 (configurable via spike_delta)
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
If a spike is detected, a **boost penalty** (`+0.25`) is added to the
|
|
290
|
+
current score to push it closer to escalation.
|
|
291
|
+
|
|
292
|
+
### Exponential Time-Decay
|
|
293
|
+
|
|
294
|
+
Historical scores are weighted using the formula:
|
|
295
|
+
|
|
296
|
+
$$w_i = e^{-\lambda \, \Delta t_i}$$
|
|
297
|
+
|
|
298
|
+
where **λ = ln(2) / half-life** and **Δt** is the age of each score in
|
|
299
|
+
seconds. This means:
|
|
300
|
+
|
|
301
|
+
| Time elapsed | Weight (24 h half-life) | Meaning |
|
|
302
|
+
|---|---|---|
|
|
303
|
+
| 5 minutes | 99.8 % | Near-full weight — live conversation |
|
|
304
|
+
| 6 hours | 84 % | Still highly relevant |
|
|
305
|
+
| 24 hours | 50 % | Half weight — yesterday's scores |
|
|
306
|
+
| 48 hours | 25 % | Faded — two days ago |
|
|
307
|
+
| 72 hours | 12.5 % | Nearly forgotten |
|
|
308
|
+
|
|
309
|
+
**Why this matters:** Without decay, a user who had a tough conversation
|
|
310
|
+
on Monday would carry that elevated baseline into Thursday—unfairly
|
|
311
|
+
triggering spikes on innocuous messages. With a 24-hour half-life,
|
|
312
|
+
old scores gracefully fade while rapid within-session escalation is
|
|
313
|
+
still caught instantly.
|
|
314
|
+
|
|
315
|
+
### Configuration
|
|
316
|
+
|
|
317
|
+
```yaml
|
|
318
|
+
trajectory:
|
|
319
|
+
window_size: 5 # messages in rolling window
|
|
320
|
+
spike_delta: 0.35 # delta threshold for spike detection
|
|
321
|
+
|
|
322
|
+
# Half-life in hours. After this period, a historical score
|
|
323
|
+
# carries only 50 % of its original weight.
|
|
324
|
+
# 24 → balanced forgiveness + familiarity (default)
|
|
325
|
+
# 6 → aggressive decay, only very recent history matters
|
|
326
|
+
# 72 → gentle decay, multi-day memory
|
|
327
|
+
# 0 → disable decay (plain unweighted mean)
|
|
328
|
+
decay_half_life_hours: 24.0
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
Or via environment variable:
|
|
332
|
+
|
|
333
|
+
```bash
|
|
334
|
+
export HUMANE_PROXY_DECAY_HALF_LIFE=12 # 12-hour half-life
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
---
|
|
338
|
+
|
|
276
339
|
## Alert Webhooks
|
|
277
340
|
|
|
278
341
|
Configure in `humane_proxy.yaml`:
|
|
@@ -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.3.
|
|
4
|
+
"version": "0.3.1",
|
|
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.3.
|
|
14
|
+
"version": "0.3.1",
|
|
15
15
|
"packageArguments": [
|
|
16
16
|
{
|
|
17
17
|
"type": "positional",
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Tests for humane_proxy.risk.trajectory (Phase 2 enhancements + time-decay)."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import time
|
|
5
|
+
from collections import deque
|
|
6
|
+
from unittest.mock import patch
|
|
7
|
+
|
|
8
|
+
from humane_proxy.risk.trajectory import (
|
|
9
|
+
analyze,
|
|
10
|
+
detect_spike,
|
|
11
|
+
session_history,
|
|
12
|
+
_category_history,
|
|
13
|
+
_weighted_mean,
|
|
14
|
+
)
|
|
15
|
+
from humane_proxy.classifiers.models import TrajectoryResult
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TestSpikeDetection:
|
|
19
|
+
def test_first_message_no_spike(self):
|
|
20
|
+
sid = "traj-first-v3"
|
|
21
|
+
assert detect_spike(sid, 0.0) is False
|
|
22
|
+
|
|
23
|
+
def test_stable_low_scores_no_spike(self):
|
|
24
|
+
sid = "traj-stable-v3"
|
|
25
|
+
for _ in range(5):
|
|
26
|
+
assert detect_spike(sid, 0.1) is False
|
|
27
|
+
|
|
28
|
+
def test_sudden_spike_detected(self):
|
|
29
|
+
sid = "traj-spike-v3"
|
|
30
|
+
for _ in range(3):
|
|
31
|
+
detect_spike(sid, 0.1)
|
|
32
|
+
assert detect_spike(sid, 0.9) is True
|
|
33
|
+
|
|
34
|
+
def test_gradual_increase_no_spike(self):
|
|
35
|
+
sid = "traj-gradual-v3"
|
|
36
|
+
for s in [0.1, 0.2, 0.3, 0.4, 0.5]:
|
|
37
|
+
result = detect_spike(sid, s)
|
|
38
|
+
assert result is False
|
|
39
|
+
|
|
40
|
+
def test_spike_after_zeros(self):
|
|
41
|
+
sid = "traj-zero-spike-v3"
|
|
42
|
+
for _ in range(5):
|
|
43
|
+
detect_spike(sid, 0.0)
|
|
44
|
+
assert detect_spike(sid, 0.5) is True
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TestTimeDecay:
|
|
48
|
+
"""Test exponential decay mechanics."""
|
|
49
|
+
|
|
50
|
+
def test_recent_scores_full_weight(self):
|
|
51
|
+
"""Scores from seconds ago should be nearly full weight."""
|
|
52
|
+
now = time.time()
|
|
53
|
+
history = deque([
|
|
54
|
+
(0.5, now - 1), # 1 second ago
|
|
55
|
+
(0.5, now - 2), # 2 seconds ago
|
|
56
|
+
])
|
|
57
|
+
avg = _weighted_mean(history, now)
|
|
58
|
+
# Should be very close to 0.5 (negligible decay within seconds).
|
|
59
|
+
assert abs(avg - 0.5) < 0.01
|
|
60
|
+
|
|
61
|
+
def test_old_scores_decay(self):
|
|
62
|
+
"""Scores from 48 hours ago should carry ~25 % weight (two half-lives)."""
|
|
63
|
+
now = time.time()
|
|
64
|
+
two_days_ago = now - (48 * 3600)
|
|
65
|
+
history = deque([
|
|
66
|
+
(0.8, two_days_ago), # old high score
|
|
67
|
+
(0.2, now - 1), # recent low score
|
|
68
|
+
])
|
|
69
|
+
avg = _weighted_mean(history, now)
|
|
70
|
+
# Old score decayed by ~75 %, recent at ~100 %.
|
|
71
|
+
# Weighted toward 0.2 rather than the plain mean of 0.5.
|
|
72
|
+
assert avg < 0.40
|
|
73
|
+
|
|
74
|
+
def test_decay_disabled_when_zero(self):
|
|
75
|
+
"""When λ = 0, _weighted_mean collapses to plain mean."""
|
|
76
|
+
now = time.time()
|
|
77
|
+
long_ago = now - (7 * 24 * 3600) # a week ago
|
|
78
|
+
history = deque([
|
|
79
|
+
(0.8, long_ago),
|
|
80
|
+
(0.2, now),
|
|
81
|
+
])
|
|
82
|
+
with patch("humane_proxy.risk.trajectory._DECAY_LAMBDA", 0.0):
|
|
83
|
+
avg = _weighted_mean(history, now)
|
|
84
|
+
assert abs(avg - 0.5) < 0.01 # plain average of 0.8 and 0.2
|
|
85
|
+
|
|
86
|
+
def test_spike_after_long_absence(self):
|
|
87
|
+
"""Returning after days with a moderate score should spike
|
|
88
|
+
because decayed history → near-zero baseline."""
|
|
89
|
+
sid = "traj-decay-spike"
|
|
90
|
+
now = time.time()
|
|
91
|
+
three_days = 3 * 24 * 3600
|
|
92
|
+
# Manually insert old history with timestamps from 3 days ago.
|
|
93
|
+
session_history[sid] = deque(maxlen=5)
|
|
94
|
+
session_history[sid].append((0.1, now - three_days))
|
|
95
|
+
session_history[sid].append((0.1, now - three_days + 1))
|
|
96
|
+
session_history[sid].append((0.1, now - three_days + 2))
|
|
97
|
+
|
|
98
|
+
# A moderate score of 0.5 should spike against the decayed baseline.
|
|
99
|
+
result = detect_spike(sid, 0.5)
|
|
100
|
+
assert result is True
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class TestAnalyze:
|
|
104
|
+
def test_returns_trajectory_result(self):
|
|
105
|
+
result = analyze("analyze-test-v3", 0.5, "safe")
|
|
106
|
+
assert isinstance(result, TrajectoryResult)
|
|
107
|
+
|
|
108
|
+
def test_first_message_stable(self):
|
|
109
|
+
result = analyze("analyze-first-v3", 0.1, "safe")
|
|
110
|
+
assert result.spike_detected is False
|
|
111
|
+
assert result.trend == "stable"
|
|
112
|
+
assert result.message_count == 1
|
|
113
|
+
assert result.category_counts == {"safe": 1}
|
|
114
|
+
|
|
115
|
+
def test_spike_detected(self):
|
|
116
|
+
sid = "analyze-spike-v3"
|
|
117
|
+
for _ in range(3):
|
|
118
|
+
analyze(sid, 0.1, "safe")
|
|
119
|
+
result = analyze(sid, 0.9, "self_harm")
|
|
120
|
+
assert result.spike_detected is True
|
|
121
|
+
|
|
122
|
+
def test_category_tracking(self):
|
|
123
|
+
sid = "analyze-cats-v3"
|
|
124
|
+
analyze(sid, 0.0, "safe")
|
|
125
|
+
analyze(sid, 0.0, "safe")
|
|
126
|
+
analyze(sid, 0.8, "self_harm")
|
|
127
|
+
result = analyze(sid, 0.0, "safe")
|
|
128
|
+
assert result.category_counts["safe"] == 3
|
|
129
|
+
assert result.category_counts["self_harm"] == 1
|
|
130
|
+
|
|
131
|
+
def test_window_scores_are_raw(self):
|
|
132
|
+
"""window_scores should return raw floats, not tuples."""
|
|
133
|
+
sid = "analyze-raw-v3"
|
|
134
|
+
analyze(sid, 0.3, "safe")
|
|
135
|
+
result = analyze(sid, 0.7, "criminal_intent")
|
|
136
|
+
for s in result.window_scores:
|
|
137
|
+
assert isinstance(s, float)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class TestTrendDetection:
|
|
141
|
+
def test_escalating_trend(self):
|
|
142
|
+
sid = "trend-escalate-v3"
|
|
143
|
+
for s in [0.1, 0.1, 0.5, 0.6]:
|
|
144
|
+
result = analyze(sid, s, "safe")
|
|
145
|
+
# first half avg: 0.1, second half avg: 0.55 → delta 0.45 > 0.15
|
|
146
|
+
assert result.trend == "escalating"
|
|
147
|
+
|
|
148
|
+
def test_declining_trend(self):
|
|
149
|
+
sid = "trend-decline-v3"
|
|
150
|
+
for s in [0.8, 0.7, 0.2, 0.1]:
|
|
151
|
+
result = analyze(sid, s, "safe")
|
|
152
|
+
# first half avg: 0.75, second half avg: 0.15 → delta -0.6 < -0.15
|
|
153
|
+
assert result.trend == "declining"
|
|
154
|
+
|
|
155
|
+
def test_stable_trend(self):
|
|
156
|
+
sid = "trend-stable-v3"
|
|
157
|
+
for s in [0.3, 0.35, 0.3, 0.35]:
|
|
158
|
+
result = analyze(sid, s, "safe")
|
|
159
|
+
assert result.trend == "stable"
|
|
160
|
+
|
|
161
|
+
def test_not_enough_for_trend(self):
|
|
162
|
+
sid = "trend-short-v3"
|
|
163
|
+
result = analyze(sid, 0.5, "safe")
|
|
164
|
+
assert result.trend == "stable"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class TestMemoryEviction:
|
|
168
|
+
def test_session_cap(self):
|
|
169
|
+
for i in range(1001):
|
|
170
|
+
session_history[f"evict-test-v3-{i}"] = deque(
|
|
171
|
+
[(0.1, time.time())], maxlen=5
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
detect_spike("evict-new-v3", 0.5)
|
|
175
|
+
assert len(session_history) <= 1001
|
|
176
|
+
|
|
177
|
+
def test_eviction_preserves_new_sessions(self):
|
|
178
|
+
sid = "evict-survivor-v3"
|
|
179
|
+
detect_spike(sid, 0.3)
|
|
180
|
+
assert sid in session_history
|
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
"""Tests for humane_proxy.risk.trajectory (Phase 2 enhancements)."""
|
|
2
|
-
|
|
3
|
-
from humane_proxy.risk.trajectory import (
|
|
4
|
-
analyze,
|
|
5
|
-
detect_spike,
|
|
6
|
-
session_history,
|
|
7
|
-
_category_history,
|
|
8
|
-
)
|
|
9
|
-
from humane_proxy.classifiers.models import TrajectoryResult
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class TestSpikeDetection:
|
|
13
|
-
def test_first_message_no_spike(self):
|
|
14
|
-
sid = "traj-first-v2"
|
|
15
|
-
assert detect_spike(sid, 0.0) is False
|
|
16
|
-
|
|
17
|
-
def test_stable_low_scores_no_spike(self):
|
|
18
|
-
sid = "traj-stable-v2"
|
|
19
|
-
for _ in range(5):
|
|
20
|
-
assert detect_spike(sid, 0.1) is False
|
|
21
|
-
|
|
22
|
-
def test_sudden_spike_detected(self):
|
|
23
|
-
sid = "traj-spike-v2"
|
|
24
|
-
for _ in range(3):
|
|
25
|
-
detect_spike(sid, 0.1)
|
|
26
|
-
assert detect_spike(sid, 0.9) is True
|
|
27
|
-
|
|
28
|
-
def test_gradual_increase_no_spike(self):
|
|
29
|
-
sid = "traj-gradual-v2"
|
|
30
|
-
for s in [0.1, 0.2, 0.3, 0.4, 0.5]:
|
|
31
|
-
result = detect_spike(sid, s)
|
|
32
|
-
assert result is False
|
|
33
|
-
|
|
34
|
-
def test_spike_after_zeros(self):
|
|
35
|
-
sid = "traj-zero-spike-v2"
|
|
36
|
-
for _ in range(5):
|
|
37
|
-
detect_spike(sid, 0.0)
|
|
38
|
-
assert detect_spike(sid, 0.5) is True
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
class TestAnalyze:
|
|
42
|
-
def test_returns_trajectory_result(self):
|
|
43
|
-
result = analyze("analyze-test-1", 0.5, "safe")
|
|
44
|
-
assert isinstance(result, TrajectoryResult)
|
|
45
|
-
|
|
46
|
-
def test_first_message_stable(self):
|
|
47
|
-
result = analyze("analyze-first", 0.1, "safe")
|
|
48
|
-
assert result.spike_detected is False
|
|
49
|
-
assert result.trend == "stable"
|
|
50
|
-
assert result.message_count == 1
|
|
51
|
-
assert result.category_counts == {"safe": 1}
|
|
52
|
-
|
|
53
|
-
def test_spike_detected(self):
|
|
54
|
-
sid = "analyze-spike"
|
|
55
|
-
for _ in range(3):
|
|
56
|
-
analyze(sid, 0.1, "safe")
|
|
57
|
-
result = analyze(sid, 0.9, "self_harm")
|
|
58
|
-
assert result.spike_detected is True
|
|
59
|
-
|
|
60
|
-
def test_category_tracking(self):
|
|
61
|
-
sid = "analyze-cats"
|
|
62
|
-
analyze(sid, 0.0, "safe")
|
|
63
|
-
analyze(sid, 0.0, "safe")
|
|
64
|
-
analyze(sid, 0.8, "self_harm")
|
|
65
|
-
result = analyze(sid, 0.0, "safe")
|
|
66
|
-
assert result.category_counts["safe"] == 3
|
|
67
|
-
assert result.category_counts["self_harm"] == 1
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
class TestTrendDetection:
|
|
71
|
-
def test_escalating_trend(self):
|
|
72
|
-
sid = "trend-escalate"
|
|
73
|
-
for s in [0.1, 0.1, 0.5, 0.6]:
|
|
74
|
-
result = analyze(sid, s, "safe")
|
|
75
|
-
# first half avg: 0.1, second half avg: 0.55 → delta 0.45 > 0.15
|
|
76
|
-
assert result.trend == "escalating"
|
|
77
|
-
|
|
78
|
-
def test_declining_trend(self):
|
|
79
|
-
sid = "trend-decline"
|
|
80
|
-
for s in [0.8, 0.7, 0.2, 0.1]:
|
|
81
|
-
result = analyze(sid, s, "safe")
|
|
82
|
-
# first half avg: 0.75, second half avg: 0.15 → delta -0.6 < -0.15
|
|
83
|
-
assert result.trend == "declining"
|
|
84
|
-
|
|
85
|
-
def test_stable_trend(self):
|
|
86
|
-
sid = "trend-stable"
|
|
87
|
-
for s in [0.3, 0.35, 0.3, 0.35]:
|
|
88
|
-
result = analyze(sid, s, "safe")
|
|
89
|
-
assert result.trend == "stable"
|
|
90
|
-
|
|
91
|
-
def test_not_enough_for_trend(self):
|
|
92
|
-
sid = "trend-short"
|
|
93
|
-
result = analyze(sid, 0.5, "safe")
|
|
94
|
-
assert result.trend == "stable"
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
class TestMemoryEviction:
|
|
98
|
-
def test_session_cap(self):
|
|
99
|
-
for i in range(1001):
|
|
100
|
-
session_history[f"evict-test-v2-{i}"] = __import__("collections").deque([0.1], maxlen=5)
|
|
101
|
-
|
|
102
|
-
detect_spike("evict-new-v2", 0.5)
|
|
103
|
-
assert len(session_history) <= 1001
|
|
104
|
-
|
|
105
|
-
def test_eviction_preserves_new_sessions(self):
|
|
106
|
-
sid = "evict-survivor-v2"
|
|
107
|
-
detect_spike(sid, 0.3)
|
|
108
|
-
assert sid in session_history
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/classifiers/stage3/openai_moderation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|