badass-runner 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- badass_runner/__init__.py +1 -0
- badass_runner/classifier/__init__.py +15 -0
- badass_runner/classifier/classifier.py +344 -0
- badass_runner/cli.py +506 -0
- badass_runner/client.py +243 -0
- badass_runner/config.py +68 -0
- badass_runner/harness/__init__.py +10 -0
- badass_runner/harness/executor.py +441 -0
- badass_runner/harness/job_poller.py +248 -0
- badass_runner/harness/sanitize.py +116 -0
- badass_runner/heartbeat.py +82 -0
- badass_runner/logs.py +38 -0
- badass_runner/recorder/__init__.py +1 -0
- badass_runner/recorder/proxy.py +220 -0
- badass_runner/recorder/redact.py +151 -0
- badass_runner/recorder/session.py +89 -0
- badass_runner/recorder/storage.py +76 -0
- badass_runner/recorder_cli.py +427 -0
- badass_runner/status_server.py +59 -0
- badass_runner/target/__init__.py +16 -0
- badass_runner/target/builder.py +245 -0
- badass_runner/target/validator.py +344 -0
- badass_runner-0.3.0.dist-info/METADATA +12 -0
- badass_runner-0.3.0.dist-info/RECORD +28 -0
- badass_runner-0.3.0.dist-info/WHEEL +5 -0
- badass_runner-0.3.0.dist-info/entry_points.txt +2 -0
- badass_runner-0.3.0.dist-info/licenses/LICENSE +187 -0
- badass_runner-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from .classifier import (
|
|
2
|
+
AI_CONFIDENCE_THRESHOLD,
|
|
3
|
+
ClassificationResult,
|
|
4
|
+
SessionClassification,
|
|
5
|
+
classify_capture,
|
|
6
|
+
classify_session,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"AI_CONFIDENCE_THRESHOLD",
|
|
11
|
+
"ClassificationResult",
|
|
12
|
+
"SessionClassification",
|
|
13
|
+
"classify_capture",
|
|
14
|
+
"classify_session",
|
|
15
|
+
]
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Heuristic AI request classifier for Local Runner recorder sessions.
|
|
2
|
+
|
|
3
|
+
Architecture
|
|
4
|
+
------------
|
|
5
|
+
* Pure Python — no LLM, no cloud call, no external dependencies.
|
|
6
|
+
* Accepts ``Capture`` dataclass instances (in-memory) **or** raw dicts
|
|
7
|
+
loaded from JSONL storage — normalised on entry.
|
|
8
|
+
* ``classify_capture`` scores a single request/response pair.
|
|
9
|
+
* ``classify_session`` scores all captures and returns the highest-confidence
|
|
10
|
+
candidate plus the full result list.
|
|
11
|
+
|
|
12
|
+
Scoring (raw points, capped at MAX_SCORE=100, then divided to 0.0–1.0)
|
|
13
|
+
-----------------------------------------------------------------------
|
|
14
|
+
POST method +25 form-urlencoded CT +8
|
|
15
|
+
JSON content-type +15 non-HTML response CT +5
|
|
16
|
+
Prompt field detected +30 AI keyword in path +10
|
|
17
|
+
Response field detected +20
|
|
18
|
+
Generated-text bonus +15 (additive with response field)
|
|
19
|
+
|
|
20
|
+
Hard exclusions (confidence = 0, ``likely_ai`` = False immediately)
|
|
21
|
+
--------------------------------------------------------------------
|
|
22
|
+
* Static asset extension in path (.css .js .png …)
|
|
23
|
+
* Exact health-check path (/health /ping /metrics …)
|
|
24
|
+
* Response content-type is HTML, CSS, JS, image, font, or octet-stream
|
|
25
|
+
|
|
26
|
+
False-positive handling
|
|
27
|
+
-----------------------
|
|
28
|
+
A plain POST JSON endpoint with no AI field names scores only 40/100 = 0.40,
|
|
29
|
+
which falls below AI_CONFIDENCE_THRESHOLD (0.50). A real AI endpoint that has
|
|
30
|
+
both a prompt field and a response field scores ≥ 0.70.
|
|
31
|
+
The generated-text bonus (+15) only fires when the response field value is a
|
|
32
|
+
multi-word string of ≥ 20 chars — it does not trigger on IDs or short values.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
import re
|
|
37
|
+
from dataclasses import dataclass, field
|
|
38
|
+
from typing import Any, Dict, List, Optional
|
|
39
|
+
from urllib.parse import parse_qs
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# Constants
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
PROMPT_FIELDS: frozenset = frozenset({
|
|
46
|
+
"message",
|
|
47
|
+
"messages",
|
|
48
|
+
"prompt",
|
|
49
|
+
"query",
|
|
50
|
+
"input",
|
|
51
|
+
"question",
|
|
52
|
+
"text",
|
|
53
|
+
"user_input",
|
|
54
|
+
"content",
|
|
55
|
+
"user_message",
|
|
56
|
+
"human",
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
RESPONSE_FIELDS: frozenset = frozenset({
|
|
60
|
+
"response",
|
|
61
|
+
"reply",
|
|
62
|
+
"answer",
|
|
63
|
+
"output",
|
|
64
|
+
"result",
|
|
65
|
+
"completion",
|
|
66
|
+
"generated_text",
|
|
67
|
+
"choices",
|
|
68
|
+
"text",
|
|
69
|
+
"content",
|
|
70
|
+
"assistant",
|
|
71
|
+
"bot_message",
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
_STATIC_EXTENSIONS: frozenset = frozenset({
|
|
75
|
+
".css", ".js", ".jsx", ".ts", ".tsx",
|
|
76
|
+
".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg", ".webp",
|
|
77
|
+
".woff", ".woff2", ".ttf", ".eot",
|
|
78
|
+
".map", ".html", ".htm", ".pdf",
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
_HEALTH_PATHS_EXACT: frozenset = frozenset({
|
|
82
|
+
"/health", "/healthz", "/ping", "/ready", "/alive",
|
|
83
|
+
"/favicon.ico", "/metrics", "/robots.txt", "/status",
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
_NON_AI_RESPONSE_CT_PREFIXES: tuple = (
|
|
87
|
+
"text/html",
|
|
88
|
+
"text/css",
|
|
89
|
+
"text/javascript",
|
|
90
|
+
"application/javascript",
|
|
91
|
+
"image/",
|
|
92
|
+
"font/",
|
|
93
|
+
"application/octet-stream",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
_AI_PATH_RE = re.compile(
|
|
97
|
+
r"(chat|complet|generat|predict|infer|ask|answer|prompt|message|"
|
|
98
|
+
r"query|llm|ai|nlp|gpt|bert|embed|assistant|completions)",
|
|
99
|
+
re.IGNORECASE,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Scoring weights
|
|
103
|
+
_W_POST = 25
|
|
104
|
+
_W_JSON_CT = 15
|
|
105
|
+
_W_FORM_CT = 8
|
|
106
|
+
_W_PROMPT_FIELD = 30
|
|
107
|
+
_W_RESPONSE_FIELD = 20
|
|
108
|
+
_W_GENERATED_TEXT = 15
|
|
109
|
+
_W_NON_HTML_RESP = 5
|
|
110
|
+
_W_AI_PATH = 10
|
|
111
|
+
MAX_SCORE = 100
|
|
112
|
+
|
|
113
|
+
AI_CONFIDENCE_THRESHOLD = 0.50
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# Output dataclasses
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class ClassificationResult:
|
|
122
|
+
likely_ai: bool
|
|
123
|
+
confidence: float
|
|
124
|
+
detected_prompt_field: Optional[str]
|
|
125
|
+
detected_response_field: Optional[str]
|
|
126
|
+
method: str
|
|
127
|
+
path: str
|
|
128
|
+
content_type: str
|
|
129
|
+
response_preview: str
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class SessionClassification:
|
|
134
|
+
best: Optional[ClassificationResult]
|
|
135
|
+
all_results: List[ClassificationResult] = field(default_factory=list)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
# Internal helpers
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
def _normalise(capture: Any) -> tuple:
|
|
143
|
+
"""Return (req_dict, resp_dict) regardless of whether capture is a
|
|
144
|
+
``Capture`` dataclass or a plain dict loaded from JSONL."""
|
|
145
|
+
if hasattr(capture, "request"):
|
|
146
|
+
req = capture.request or {}
|
|
147
|
+
resp = capture.response or {}
|
|
148
|
+
else:
|
|
149
|
+
req = capture.get("request") or {}
|
|
150
|
+
resp = capture.get("response") or {}
|
|
151
|
+
return req, resp
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _ext(path: str) -> str:
|
|
155
|
+
"""Return lowercase file extension from path, stripping query/fragment."""
|
|
156
|
+
bare = path.split("?")[0].split("#")[0]
|
|
157
|
+
dot = bare.rfind(".")
|
|
158
|
+
slash = bare.rfind("/")
|
|
159
|
+
return bare[dot:].lower() if dot > slash else ""
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _is_static(path: str) -> bool:
|
|
163
|
+
return _ext(path) in _STATIC_EXTENSIONS
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _is_health_check(path: str) -> bool:
|
|
167
|
+
return path.lower().rstrip("/") in _HEALTH_PATHS_EXACT
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _response_ct_excluded(ct: str) -> bool:
|
|
171
|
+
ct_low = (ct or "").lower()
|
|
172
|
+
return any(ct_low.startswith(p) for p in _NON_AI_RESPONSE_CT_PREFIXES)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _parse_json_fields(snippet: str) -> Dict[str, Any]:
|
|
176
|
+
"""Try to parse snippet as JSON; return the top-level dict or {}."""
|
|
177
|
+
if not snippet:
|
|
178
|
+
return {}
|
|
179
|
+
try:
|
|
180
|
+
obj = json.loads(snippet)
|
|
181
|
+
if isinstance(obj, dict):
|
|
182
|
+
return obj
|
|
183
|
+
except (ValueError, json.JSONDecodeError):
|
|
184
|
+
pass
|
|
185
|
+
return {}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _parse_form_fields(snippet: str) -> Dict[str, Any]:
|
|
189
|
+
"""Parse application/x-www-form-urlencoded body snippet."""
|
|
190
|
+
if not snippet:
|
|
191
|
+
return {}
|
|
192
|
+
try:
|
|
193
|
+
parsed = parse_qs(snippet, keep_blank_values=False)
|
|
194
|
+
return {k: (v[0] if len(v) == 1 else v) for k, v in parsed.items()}
|
|
195
|
+
except Exception:
|
|
196
|
+
return {}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _find_field(body: Dict[str, Any], candidates: frozenset) -> Optional[str]:
|
|
200
|
+
"""Return the first matching candidate key (case-insensitive), or None."""
|
|
201
|
+
lower_map = {k.lower(): k for k in body}
|
|
202
|
+
for candidate in sorted(candidates):
|
|
203
|
+
if candidate in lower_map:
|
|
204
|
+
return lower_map[candidate]
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _looks_like_generated_text(value: Any) -> bool:
|
|
209
|
+
"""Return True if *value* looks like a genuine natural-language output."""
|
|
210
|
+
if isinstance(value, list):
|
|
211
|
+
if not value:
|
|
212
|
+
return False
|
|
213
|
+
first = value[0]
|
|
214
|
+
if isinstance(first, dict):
|
|
215
|
+
value = (
|
|
216
|
+
first.get("text")
|
|
217
|
+
or first.get("content")
|
|
218
|
+
or (first.get("message") or {}).get("content")
|
|
219
|
+
or str(first)
|
|
220
|
+
)
|
|
221
|
+
else:
|
|
222
|
+
value = str(first)
|
|
223
|
+
if not isinstance(value, str):
|
|
224
|
+
return False
|
|
225
|
+
stripped = value.strip()
|
|
226
|
+
if len(stripped) < 20:
|
|
227
|
+
return False
|
|
228
|
+
if len(stripped.split()) < 3:
|
|
229
|
+
return False
|
|
230
|
+
if stripped.startswith(("http://", "https://", "data:", "urn:")):
|
|
231
|
+
return False
|
|
232
|
+
return True
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# ---------------------------------------------------------------------------
|
|
236
|
+
# Core scorer
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
def _build_result(
|
|
240
|
+
score: int,
|
|
241
|
+
method: str,
|
|
242
|
+
path: str,
|
|
243
|
+
content_type: str,
|
|
244
|
+
response_preview: str,
|
|
245
|
+
prompt_field: Optional[str],
|
|
246
|
+
response_field: Optional[str],
|
|
247
|
+
) -> ClassificationResult:
|
|
248
|
+
confidence = round(min(score / MAX_SCORE, 1.0), 4)
|
|
249
|
+
return ClassificationResult(
|
|
250
|
+
likely_ai=confidence >= AI_CONFIDENCE_THRESHOLD,
|
|
251
|
+
confidence=confidence,
|
|
252
|
+
detected_prompt_field=prompt_field,
|
|
253
|
+
detected_response_field=response_field,
|
|
254
|
+
method=method,
|
|
255
|
+
path=path,
|
|
256
|
+
content_type=content_type,
|
|
257
|
+
response_preview=response_preview,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def classify_capture(capture: Any) -> ClassificationResult:
|
|
262
|
+
"""Score a single capture and return a :class:`ClassificationResult`.
|
|
263
|
+
|
|
264
|
+
*capture* may be a ``Capture`` dataclass instance (in-memory session) or a
|
|
265
|
+
plain dict loaded from JSONL storage — both are normalised transparently.
|
|
266
|
+
"""
|
|
267
|
+
req, resp = _normalise(capture)
|
|
268
|
+
|
|
269
|
+
method = (req.get("method") or "GET").upper()
|
|
270
|
+
path = req.get("path") or "/"
|
|
271
|
+
req_ct = req.get("content_type") or ""
|
|
272
|
+
body_snippet = req.get("body_snippet") or ""
|
|
273
|
+
resp_ct = resp.get("content_type") or ""
|
|
274
|
+
resp_preview = (resp.get("body_snippet") or "")[:200]
|
|
275
|
+
|
|
276
|
+
# ---- hard exclusions ------------------------------------------------
|
|
277
|
+
if _is_static(path) or _is_health_check(path) or _response_ct_excluded(resp_ct):
|
|
278
|
+
return _build_result(0, method, path, req_ct, resp_preview, None, None)
|
|
279
|
+
|
|
280
|
+
score = 0
|
|
281
|
+
|
|
282
|
+
# ---- method ---------------------------------------------------------
|
|
283
|
+
if method == "POST":
|
|
284
|
+
score += _W_POST
|
|
285
|
+
|
|
286
|
+
# ---- request content-type -------------------------------------------
|
|
287
|
+
req_ct_low = req_ct.lower()
|
|
288
|
+
is_json = "application/json" in req_ct_low
|
|
289
|
+
is_form = "application/x-www-form-urlencoded" in req_ct_low
|
|
290
|
+
|
|
291
|
+
if is_json:
|
|
292
|
+
score += _W_JSON_CT
|
|
293
|
+
elif is_form:
|
|
294
|
+
score += _W_FORM_CT
|
|
295
|
+
|
|
296
|
+
# ---- body field detection -------------------------------------------
|
|
297
|
+
if is_json:
|
|
298
|
+
req_fields = _parse_json_fields(body_snippet)
|
|
299
|
+
elif is_form:
|
|
300
|
+
req_fields = _parse_form_fields(body_snippet)
|
|
301
|
+
else:
|
|
302
|
+
req_fields = {}
|
|
303
|
+
|
|
304
|
+
prompt_field = _find_field(req_fields, PROMPT_FIELDS)
|
|
305
|
+
if prompt_field:
|
|
306
|
+
score += _W_PROMPT_FIELD
|
|
307
|
+
|
|
308
|
+
# ---- response field detection ---------------------------------------
|
|
309
|
+
resp_fields = _parse_json_fields(resp_preview)
|
|
310
|
+
response_field = _find_field(resp_fields, RESPONSE_FIELDS)
|
|
311
|
+
if response_field:
|
|
312
|
+
score += _W_RESPONSE_FIELD
|
|
313
|
+
if _looks_like_generated_text(resp_fields.get(response_field)):
|
|
314
|
+
score += _W_GENERATED_TEXT
|
|
315
|
+
|
|
316
|
+
# ---- response content-type signal -----------------------------------
|
|
317
|
+
if resp_ct and not resp_ct.lower().startswith("text/html"):
|
|
318
|
+
score += _W_NON_HTML_RESP
|
|
319
|
+
|
|
320
|
+
# ---- AI keyword in path ---------------------------------------------
|
|
321
|
+
if _AI_PATH_RE.search(path):
|
|
322
|
+
score += _W_AI_PATH
|
|
323
|
+
|
|
324
|
+
return _build_result(score, method, path, req_ct, resp_preview, prompt_field, response_field)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# ---------------------------------------------------------------------------
|
|
328
|
+
# Session-level classifier
|
|
329
|
+
# ---------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
def classify_session(captures) -> SessionClassification:
|
|
332
|
+
"""Classify all captures in *captures* and return a :class:`SessionClassification`.
|
|
333
|
+
|
|
334
|
+
The ``best`` field is the highest-confidence ``likely_ai`` result, or the
|
|
335
|
+
overall highest-confidence result when no capture clears the threshold.
|
|
336
|
+
"""
|
|
337
|
+
if not captures:
|
|
338
|
+
return SessionClassification(best=None, all_results=[])
|
|
339
|
+
|
|
340
|
+
results = [classify_capture(c) for c in captures]
|
|
341
|
+
ai_results = [r for r in results if r.likely_ai]
|
|
342
|
+
pool = ai_results if ai_results else results
|
|
343
|
+
best = max(pool, key=lambda r: r.confidence)
|
|
344
|
+
return SessionClassification(best=best, all_results=results)
|