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.
@@ -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)