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.
Files changed (69) hide show
  1. {humane_proxy-0.3.0/humane_proxy.egg-info → humane_proxy-0.3.1}/PKG-INFO +64 -1
  2. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/README.md +63 -0
  3. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/__init__.py +1 -1
  4. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/cli.py +1 -1
  5. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/config.py +1 -0
  6. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/config.yaml +12 -0
  7. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/middleware/interceptor.py +1 -1
  8. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/risk/trajectory.py +63 -11
  9. {humane_proxy-0.3.0 → humane_proxy-0.3.1/humane_proxy.egg-info}/PKG-INFO +64 -1
  10. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/pyproject.toml +1 -1
  11. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/server.json +2 -2
  12. humane_proxy-0.3.1/tests/test_trajectory.py +180 -0
  13. humane_proxy-0.3.0/tests/test_trajectory.py +0 -108
  14. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/LICENSE +0 -0
  15. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/NOTICE +0 -0
  16. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/api/__init__.py +0 -0
  17. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/api/admin.py +0 -0
  18. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/classifiers/__init__.py +0 -0
  19. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/classifiers/embedding_classifier.py +0 -0
  20. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/classifiers/heuristics.py +0 -0
  21. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/classifiers/models.py +0 -0
  22. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/classifiers/pipeline.py +0 -0
  23. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/classifiers/stage3/__init__.py +0 -0
  24. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/classifiers/stage3/base.py +0 -0
  25. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/classifiers/stage3/llamaguard.py +0 -0
  26. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/classifiers/stage3/openai_chat.py +0 -0
  27. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/classifiers/stage3/openai_moderation.py +0 -0
  28. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/escalation/__init__.py +0 -0
  29. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/escalation/local_db.py +0 -0
  30. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/escalation/router.py +0 -0
  31. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/escalation/webhooks.py +0 -0
  32. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/integrations/__init__.py +0 -0
  33. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/integrations/autogen.py +0 -0
  34. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/integrations/crewai.py +0 -0
  35. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/integrations/langchain.py +0 -0
  36. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/integrations/llamaindex.py +0 -0
  37. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/mcp_server.py +0 -0
  38. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/middleware/__init__.py +0 -0
  39. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/risk/__init__.py +0 -0
  40. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/storage/__init__.py +0 -0
  41. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/storage/base.py +0 -0
  42. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/storage/factory.py +0 -0
  43. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/storage/postgres.py +0 -0
  44. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/storage/redis.py +0 -0
  45. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy/storage/sqlite.py +0 -0
  46. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy.egg-info/SOURCES.txt +0 -0
  47. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy.egg-info/dependency_links.txt +0 -0
  48. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy.egg-info/entry_points.txt +0 -0
  49. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy.egg-info/requires.txt +0 -0
  50. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/humane_proxy.egg-info/top_level.txt +0 -0
  51. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/setup.cfg +0 -0
  52. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/tests/test_admin_api.py +0 -0
  53. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/tests/test_care_response.py +0 -0
  54. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/tests/test_cli.py +0 -0
  55. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/tests/test_config.py +0 -0
  56. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/tests/test_embedding_classifier.py +0 -0
  57. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/tests/test_embedding_singleton.py +0 -0
  58. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/tests/test_enhanced_webhooks.py +0 -0
  59. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/tests/test_heuristics.py +0 -0
  60. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/tests/test_integrations_smoke.py +0 -0
  61. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/tests/test_interceptor.py +0 -0
  62. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/tests/test_local_db.py +0 -0
  63. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/tests/test_models.py +0 -0
  64. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/tests/test_pipeline.py +0 -0
  65. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/tests/test_router.py +0 -0
  66. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/tests/test_self_harm_threshold.py +0 -0
  67. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/tests/test_stage3.py +0 -0
  68. {humane_proxy-0.3.0 → humane_proxy-0.3.1}/tests/test_storage_backends.py +0 -0
  69. {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.0
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`:
@@ -16,7 +16,7 @@
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
- __version__ = "0.3.0"
19
+ __version__ = "0.3.1"
20
20
 
21
21
  # ---------------------------------------------------------------------------
22
22
  # Legacy API — keep backward compatibility with existing modules that call
@@ -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
@@ -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.3.0",
54
+ version="0.3.1",
55
55
  description="Lightweight AI safety middleware that protects humans.",
56
56
  lifespan=_lifespan,
57
57
  )
@@ -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
- session_history: dict[str, deque[float]] = {}
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 - mean(last N scores)``
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 = mean(history)
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 = list(history)
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 = mean(scores[:mid])
135
- second_half_avg = mean(scores[mid:])
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.0
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`:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "humane-proxy"
7
- version = "0.3.0"
7
+ version = "0.3.1"
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.3.0",
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.0",
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