judgetap 0.0.2.dev27__tar.gz → 0.0.2.dev28__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.
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/PKG-INFO +1 -1
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/pyproject.toml +1 -1
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/src/judgetap/__init__.py +1 -1
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/src/judgetap/_compat.py +8 -6
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/src/judgetap/cli.py +16 -4
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/src/judgetap/dashboard/data.py +97 -6
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/src/judgetap/dashboard/server.py +9 -1
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/src/judgetap/secrets.py +3 -0
- judgetap-0.0.2.dev28/tests/test_robustness_68.py +177 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/.github/workflows/ci.yml +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/.github/workflows/demo.yml +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/.github/workflows/release.yml +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/.gitignore +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/.python-version +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/.release-please-manifest.json +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/CONTRIBUTING.md +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/LICENSE +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/README.md +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/docs/SPEC.md +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/docs/demo.tape +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/release-please-config.json +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/src/judgetap/api.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/src/judgetap/cascade.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/src/judgetap/dashboard/__init__.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/src/judgetap/dashboard/page.html +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/src/judgetap/decision_log.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/src/judgetap/engine.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/src/judgetap/engines/__init__.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/src/judgetap/engines/agentjev.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/src/judgetap/engines/jev.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/src/judgetap/engines/laya.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/src/judgetap/engines/llm.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/src/judgetap/errors.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/src/judgetap/evaluate.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/src/judgetap/guard/__init__.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/src/judgetap/guard/core.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/src/judgetap/guard/hook.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/src/judgetap/guard/install.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/src/judgetap/guard/loop.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/src/judgetap/guard/rules.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/src/judgetap/guard/stop.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/src/judgetap/py.typed +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/src/judgetap/testing.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/src/judgetap/types.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/tests/test_api.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/tests/test_call_accounting.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/tests/test_calls.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/tests/test_cascade.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/tests/test_dashboard.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/tests/test_decision_log.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/tests/test_engine_jev.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/tests/test_engine_llm.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/tests/test_engine_local.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/tests/test_evaluate.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/tests/test_guard_agents.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/tests/test_guard_core.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/tests/test_guard_hook.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/tests/test_guard_loop.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/tests/test_guard_rules.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/tests/test_guard_stop.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/tests/test_questions.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/tests/test_secrets.py +0 -0
- {judgetap-0.0.2.dev27 → judgetap-0.0.2.dev28}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: judgetap
|
|
3
|
-
Version: 0.0.2.
|
|
3
|
+
Version: 0.0.2.dev28
|
|
4
4
|
Summary: Fast typed decisions (choice, score, yes/no) across Jev-style engines, plus a guard for coding agents. Early development.
|
|
5
5
|
Project-URL: Homepage, https://github.com/mergesafe-ai/judgetap
|
|
6
6
|
Author-email: Omer Bar-Ness <omer@zsquared.io>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "judgetap"
|
|
3
|
-
version = "0.0.2.
|
|
3
|
+
version = "0.0.2.dev28"
|
|
4
4
|
description = "Fast typed decisions (choice, score, yes/no) across Jev-style engines, plus a guard for coding agents. Early development."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "Apache-2.0"
|
|
@@ -14,8 +14,11 @@ OLD_PREFIX, NEW_PREFIX = "SNAPJUDGE_", "JUDGETAP_"
|
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
def env(name: str) -> str | None:
|
|
17
|
-
"""$JUDGETAP_<name>, else the old $SNAPJUDGE_<name>.
|
|
18
|
-
|
|
17
|
+
"""$JUDGETAP_<name>, else the old $SNAPJUDGE_<name>. The new name wins
|
|
18
|
+
whenever it is set, even to an empty string (which reads as unset)."""
|
|
19
|
+
if NEW_PREFIX + name in os.environ:
|
|
20
|
+
return os.environ[NEW_PREFIX + name] or None
|
|
21
|
+
return os.environ.get(OLD_PREFIX + name) or None
|
|
19
22
|
|
|
20
23
|
|
|
21
24
|
def default_home() -> Path:
|
|
@@ -26,7 +29,6 @@ def default_home() -> Path:
|
|
|
26
29
|
|
|
27
30
|
def env_source(name: str) -> str | None:
|
|
28
31
|
"""Which variable `env(name)` read: '$JUDGETAP_<name>' or '$SNAPJUDGE_<name>'."""
|
|
29
|
-
|
|
30
|
-
if os.environ
|
|
31
|
-
|
|
32
|
-
return None
|
|
32
|
+
if NEW_PREFIX + name in os.environ: # set, even empty: the old name is ignored
|
|
33
|
+
return f"${NEW_PREFIX}{name}" if os.environ[NEW_PREFIX + name] else None
|
|
34
|
+
return f"${OLD_PREFIX}{name}" if os.environ.get(OLD_PREFIX + name) else None
|
|
@@ -190,7 +190,7 @@ def _dashboard(args) -> int:
|
|
|
190
190
|
from judgetap.guard.hook import home
|
|
191
191
|
|
|
192
192
|
server = serve(home(), args.port)
|
|
193
|
-
url = f"http://127.0.0.1:{
|
|
193
|
+
url = f"http://127.0.0.1:{server.server_address[1]}/" # the bound port (--port 0)
|
|
194
194
|
print(f"judgetap dashboard on {url} (Ctrl+C to stop); reading {home()}")
|
|
195
195
|
if not args.no_browser:
|
|
196
196
|
webbrowser.open(url)
|
|
@@ -238,8 +238,14 @@ def _offer_keychain(stdin=None) -> None:
|
|
|
238
238
|
def _keys_set(args) -> int:
|
|
239
239
|
import getpass
|
|
240
240
|
|
|
241
|
-
from judgetap.secrets import set_key
|
|
241
|
+
from judgetap.secrets import KNOWN_KEYS, set_key
|
|
242
242
|
|
|
243
|
+
if args.name not in KNOWN_KEYS:
|
|
244
|
+
print(
|
|
245
|
+
f"{args.name} isn't read by any engine; known keys: {', '.join(KNOWN_KEYS)}."
|
|
246
|
+
)
|
|
247
|
+
print("The llm engine reads its provider's key from the environment only.")
|
|
248
|
+
return 2
|
|
243
249
|
value = getpass.getpass(f"{args.name}: ") # never echoed, never in argv
|
|
244
250
|
if not value:
|
|
245
251
|
print("Nothing entered; not saved.")
|
|
@@ -254,9 +260,15 @@ def _keys_set(args) -> int:
|
|
|
254
260
|
|
|
255
261
|
|
|
256
262
|
def _keys_status(args) -> int:
|
|
257
|
-
from judgetap.secrets import key_source
|
|
263
|
+
from judgetap.secrets import KNOWN_KEYS, key_source
|
|
258
264
|
|
|
259
|
-
for
|
|
265
|
+
unknown = [n for n in args.names if n not in KNOWN_KEYS]
|
|
266
|
+
if unknown:
|
|
267
|
+
print(
|
|
268
|
+
f"Not read by any engine: {', '.join(unknown)}; known keys: {', '.join(KNOWN_KEYS)}."
|
|
269
|
+
)
|
|
270
|
+
return 2
|
|
271
|
+
for name in args.names or KNOWN_KEYS:
|
|
260
272
|
print(f"{name}: {key_source(name) or 'not set'}")
|
|
261
273
|
return 0
|
|
262
274
|
|
|
@@ -85,8 +85,12 @@ def _load(home: Path, today: date) -> dict[str, Any]:
|
|
|
85
85
|
calls: Counter = Counter() # true totals; the latency deques are capped
|
|
86
86
|
recent: deque = deque(maxlen=MAX_RECENT)
|
|
87
87
|
cost, total, false_holds, library, loops, stops = 0.0, 0, 0, 0, 0, 0
|
|
88
|
-
for n,
|
|
89
|
-
|
|
88
|
+
for n, raw in _iter_jsonl(home / "guard.jsonl"):
|
|
89
|
+
try:
|
|
90
|
+
r = _clean(raw)
|
|
91
|
+
r["id"] = record_id(r, n)
|
|
92
|
+
except Exception: # noqa: BLE001, S112 -- one unreadable record never breaks the page
|
|
93
|
+
continue
|
|
90
94
|
r["source"] = r.get("source") or "guard"
|
|
91
95
|
r["false_alarm"] = r["id"] in false_alarms
|
|
92
96
|
cost += r.get("cost_usd") or 0
|
|
@@ -118,7 +122,8 @@ def _load(home: Path, today: date) -> dict[str, Any]:
|
|
|
118
122
|
"outcomes": dict(outcomes),
|
|
119
123
|
"holds_per_1000": round(1000 * outcomes["hold"] / total, 1) if total else None,
|
|
120
124
|
"false_alarms": false_holds,
|
|
121
|
-
|
|
125
|
+
# Individually finite costs can still sum past float range.
|
|
126
|
+
"cost_usd": round(cost, 6) if math.isfinite(cost) else None,
|
|
122
127
|
"engines": {
|
|
123
128
|
name: {
|
|
124
129
|
"calls": calls[name],
|
|
@@ -132,6 +137,89 @@ def _load(home: Path, today: date) -> dict[str, Any]:
|
|
|
132
137
|
return {"summary": summary, "recent": list(recent)[::-1]}
|
|
133
138
|
|
|
134
139
|
|
|
140
|
+
STR_FIELDS = (
|
|
141
|
+
"id",
|
|
142
|
+
"ts",
|
|
143
|
+
"session",
|
|
144
|
+
"source",
|
|
145
|
+
"tool",
|
|
146
|
+
"subject",
|
|
147
|
+
"outcome",
|
|
148
|
+
"layer",
|
|
149
|
+
"rule",
|
|
150
|
+
"reason",
|
|
151
|
+
"engine",
|
|
152
|
+
"error",
|
|
153
|
+
"call",
|
|
154
|
+
"batch",
|
|
155
|
+
)
|
|
156
|
+
NUM_FIELDS = ("cost_usd", "latency_ms")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _finite(value: Any) -> float | None:
|
|
160
|
+
"""A finite int/float as float; anything else (str, bool, NaN, inf) is None."""
|
|
161
|
+
if isinstance(value, bool) or not isinstance(value, int | float):
|
|
162
|
+
return None
|
|
163
|
+
try:
|
|
164
|
+
number = float(value) # huge JSON ints overflow here: an invalid field
|
|
165
|
+
except OverflowError:
|
|
166
|
+
return None
|
|
167
|
+
return number if math.isfinite(number) else None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
MAX_DEPTH = 8 # deeper than any field the page reads
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _scrub(value: Any, depth: int = 0) -> Any:
|
|
174
|
+
"""NaN/Infinity anywhere (even in fields the page doesn't use) become None.
|
|
175
|
+
Nesting past MAX_DEPTH is cut to None, so a pathological unrelated field
|
|
176
|
+
can't exhaust the stack and cost the record."""
|
|
177
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
178
|
+
return None
|
|
179
|
+
if isinstance(value, dict | list) and depth >= MAX_DEPTH:
|
|
180
|
+
return None
|
|
181
|
+
if isinstance(value, dict):
|
|
182
|
+
return {k: _scrub(v, depth + 1) for k, v in value.items()}
|
|
183
|
+
if isinstance(value, list):
|
|
184
|
+
return [_scrub(v, depth + 1) for v in value]
|
|
185
|
+
return value
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _clean(r: dict[str, Any]) -> dict[str, Any]:
|
|
189
|
+
"""The log is ours but may be edited, truncated or written by an older
|
|
190
|
+
version: coerce every field the page uses to its expected type, so no
|
|
191
|
+
value can crash the summary or make the JSON invalid (NaN)."""
|
|
192
|
+
out = _scrub(dict(r))
|
|
193
|
+
for key in STR_FIELDS:
|
|
194
|
+
if key in out and not isinstance(out[key], str):
|
|
195
|
+
out[key] = None
|
|
196
|
+
for key in NUM_FIELDS:
|
|
197
|
+
if key in out:
|
|
198
|
+
out[key] = _finite(out[key])
|
|
199
|
+
if not isinstance(out.get("p"), dict):
|
|
200
|
+
out["p"] = {}
|
|
201
|
+
else:
|
|
202
|
+
out["p"] = {
|
|
203
|
+
k: v
|
|
204
|
+
for k, v in out["p"].items()
|
|
205
|
+
if isinstance(k, str) and _finite(v) is not None
|
|
206
|
+
}
|
|
207
|
+
if "calls" in out and not isinstance(out["calls"], list):
|
|
208
|
+
# Not a calls list at all: treat the record as having none, so the
|
|
209
|
+
# legacy fallback still counts its engine.
|
|
210
|
+
del out["calls"]
|
|
211
|
+
if "calls" in out:
|
|
212
|
+
# Calls are echoed back in "recent" too: keep only well-formed ones
|
|
213
|
+
# with a finite (or absent) latency.
|
|
214
|
+
calls = out["calls"]
|
|
215
|
+
out["calls"] = [
|
|
216
|
+
{**c, "latency_ms": _finite(c.get("latency_ms"))}
|
|
217
|
+
for c in calls
|
|
218
|
+
if isinstance(c, dict) and isinstance(c.get("engine"), str)
|
|
219
|
+
]
|
|
220
|
+
return out
|
|
221
|
+
|
|
222
|
+
|
|
135
223
|
def _count_calls(r: dict[str, Any], calls: Counter, by_engine) -> None:
|
|
136
224
|
"""Engine metrics come from the calls each record reports, each with its
|
|
137
225
|
own latency: nothing is inferred. A batch writes its calls on one record
|
|
@@ -143,14 +231,17 @@ def _count_calls(r: dict[str, Any], calls: Counter, by_engine) -> None:
|
|
|
143
231
|
for c in reported:
|
|
144
232
|
if isinstance(c, dict) and isinstance(c.get("engine"), str):
|
|
145
233
|
calls[c["engine"]] += 1
|
|
146
|
-
|
|
147
|
-
|
|
234
|
+
latency = _finite(c.get("latency_ms"))
|
|
235
|
+
if latency is not None:
|
|
236
|
+
by_engine[c["engine"]].append(latency)
|
|
148
237
|
return
|
|
149
238
|
if not r.get("engine") or r.get("error"):
|
|
150
239
|
return
|
|
151
240
|
if r.get("layer") == "judge":
|
|
152
241
|
calls[r["engine"]] += 1
|
|
153
|
-
|
|
242
|
+
latency = _finite(r.get("latency_ms"))
|
|
243
|
+
if latency is not None: # no sample for a missing or invalid latency
|
|
244
|
+
by_engine[r["engine"]].append(latency)
|
|
154
245
|
elif r.get("layer") == "stop" and r.get("outcome") in ("allow", "block"):
|
|
155
246
|
# A Stop check from before calls were logged: it asked its engine
|
|
156
247
|
# once, but recorded no latency, so only the call is counted.
|
|
@@ -44,7 +44,10 @@ def make_handler(home: Path, token: str, port: int) -> type[BaseHTTPRequestHandl
|
|
|
44
44
|
self.wfile.write(body)
|
|
45
45
|
|
|
46
46
|
def _json(self, status: int, obj: object) -> None:
|
|
47
|
-
|
|
47
|
+
# allow_nan=False: bare NaN isn't JSON and would break the page.
|
|
48
|
+
self._send(
|
|
49
|
+
status, json.dumps(obj, allow_nan=False).encode(), "application/json"
|
|
50
|
+
)
|
|
48
51
|
|
|
49
52
|
def do_GET(self) -> None:
|
|
50
53
|
if not self._host_ok():
|
|
@@ -94,4 +97,9 @@ def make_handler(home: Path, token: str, port: int) -> type[BaseHTTPRequestHandl
|
|
|
94
97
|
def serve(home: Path, port: int = 8765) -> ThreadingHTTPServer:
|
|
95
98
|
token = secrets.token_urlsafe(24)
|
|
96
99
|
server = ThreadingHTTPServer(("127.0.0.1", port), make_handler(home, token, port))
|
|
100
|
+
# Port 0 means "any free port": the Host allow-list must use the port
|
|
101
|
+
# the OS actually bound, or every request is refused.
|
|
102
|
+
bound = server.server_address[1]
|
|
103
|
+
if bound != port:
|
|
104
|
+
server.RequestHandlerClass = make_handler(home, token, bound)
|
|
97
105
|
return server
|
|
@@ -10,6 +10,9 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
import os
|
|
12
12
|
|
|
13
|
+
# Keys an engine reads through get_key. Saving any other name to the keychain
|
|
14
|
+
# would store something nothing reads.
|
|
15
|
+
KNOWN_KEYS = ("TYPESAFE_API_KEY",)
|
|
13
16
|
SERVICE = "judgetap"
|
|
14
17
|
OLD_SERVICE = "snapjudge" # keys saved before the rename (#38)
|
|
15
18
|
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import math
|
|
3
|
+
import threading
|
|
4
|
+
import urllib.request
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from judgetap.dashboard.data import load
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def write(tmp_path, rows):
|
|
12
|
+
(tmp_path / "guard.jsonl").write_text("\n".join(rows) + "\n")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.mark.parametrize(
|
|
16
|
+
"line",
|
|
17
|
+
[
|
|
18
|
+
'{"outcome":"allow","cost_usd":"0.1"}',
|
|
19
|
+
'{"outcome":["hold"]}',
|
|
20
|
+
'{"outcome":"allow","cost_usd":NaN}',
|
|
21
|
+
'{"outcome":"allow","layer":"judge","calls":[{"engine":"e","latency_ms":NaN}]}',
|
|
22
|
+
'{"outcome":"allow","layer":{"x":1},"engine":7,"source":[1],"p":"nope","calls":"x"}',
|
|
23
|
+
'{"outcome":"allow","latency_ms":Infinity,"layer":"judge","engine":"e"}',
|
|
24
|
+
],
|
|
25
|
+
)
|
|
26
|
+
def test_odd_log_values_never_break_load(tmp_path, line):
|
|
27
|
+
write(tmp_path, [json.dumps({"outcome": "hold", "layer": "rules"}), line])
|
|
28
|
+
data = load(tmp_path)
|
|
29
|
+
json.dumps(data, allow_nan=False) # strict JSON: no NaN/Infinity left
|
|
30
|
+
assert data["summary"]["outcomes"].get("hold") == 1
|
|
31
|
+
assert all(isinstance(k, str) or k is None for k in data["summary"]["outcomes"])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_nan_latency_is_not_sampled(tmp_path):
|
|
35
|
+
write(
|
|
36
|
+
tmp_path,
|
|
37
|
+
[
|
|
38
|
+
'{"outcome":"allow","layer":"judge","calls":[{"engine":"e","latency_ms":NaN},{"engine":"e","latency_ms":5}]}'
|
|
39
|
+
],
|
|
40
|
+
)
|
|
41
|
+
e = load(tmp_path)["summary"]["engines"]["e"]
|
|
42
|
+
assert e["calls"] == 2 and e["p50_ms"] == 5 and not math.isnan(e["p50_ms"])
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_api_data_answers_with_a_bad_line(tmp_path):
|
|
46
|
+
from judgetap.dashboard.server import serve
|
|
47
|
+
|
|
48
|
+
write(tmp_path, ['{"outcome":"allow","cost_usd":"0.1"}', '{"outcome":["hold"]}'])
|
|
49
|
+
srv = serve(tmp_path, 0)
|
|
50
|
+
port = srv.server_address[1]
|
|
51
|
+
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
|
52
|
+
try:
|
|
53
|
+
with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/data") as res:
|
|
54
|
+
assert res.status == 200 and json.loads(res.read())["summary"]["total"] == 2
|
|
55
|
+
finally:
|
|
56
|
+
srv.shutdown()
|
|
57
|
+
srv.server_close()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_port_zero_uses_the_bound_port(tmp_path):
|
|
61
|
+
from judgetap.dashboard.server import serve
|
|
62
|
+
|
|
63
|
+
srv = serve(tmp_path, 0)
|
|
64
|
+
port = srv.server_address[1]
|
|
65
|
+
assert port != 0
|
|
66
|
+
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
|
67
|
+
try:
|
|
68
|
+
with urllib.request.urlopen(f"http://127.0.0.1:{port}/") as res:
|
|
69
|
+
assert res.status == 200
|
|
70
|
+
finally:
|
|
71
|
+
srv.shutdown()
|
|
72
|
+
srv.server_close()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_keys_set_rejects_names_no_engine_reads(monkeypatch, capsys):
|
|
76
|
+
from judgetap.cli import main
|
|
77
|
+
|
|
78
|
+
asked = []
|
|
79
|
+
monkeypatch.setattr("getpass.getpass", lambda prompt: asked.append(prompt) or "v")
|
|
80
|
+
assert main(["keys", "set", "OPENAI_API_KEY"]) == 2
|
|
81
|
+
assert not asked and "isn't read by any engine" in capsys.readouterr().out
|
|
82
|
+
assert main(["keys", "status", "OPENAI_API_KEY"]) == 2
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_empty_new_env_wins_over_old(monkeypatch):
|
|
86
|
+
from judgetap._compat import env, env_source
|
|
87
|
+
|
|
88
|
+
monkeypatch.setenv("JUDGETAP_ENGINE", "")
|
|
89
|
+
monkeypatch.setenv("SNAPJUDGE_ENGINE", "llm")
|
|
90
|
+
assert env("ENGINE") is None and env_source("ENGINE") is None
|
|
91
|
+
monkeypatch.delenv("JUDGETAP_ENGINE")
|
|
92
|
+
assert env("ENGINE") == "llm" and env_source("ENGINE") == "$SNAPJUDGE_ENGINE"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_nan_in_unknown_fields_is_scrubbed(tmp_path):
|
|
96
|
+
write(tmp_path, ['{"outcome":"allow","extra":{"x":[NaN, -Infinity]}}'])
|
|
97
|
+
json.dumps(load(tmp_path), allow_nan=False)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_huge_int_cost_keeps_the_record(tmp_path):
|
|
101
|
+
import json
|
|
102
|
+
|
|
103
|
+
from judgetap.dashboard.data import load
|
|
104
|
+
|
|
105
|
+
(tmp_path / "guard.jsonl").write_text(
|
|
106
|
+
json.dumps({"outcome": "hold", "cost_usd": 10**400}) + "\n"
|
|
107
|
+
)
|
|
108
|
+
s = load(tmp_path)["summary"]
|
|
109
|
+
assert s["total"] == 1 and s["outcomes"] == {"hold": 1}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_invalid_legacy_latency_adds_no_sample(tmp_path):
|
|
113
|
+
from judgetap.dashboard.data import load
|
|
114
|
+
|
|
115
|
+
(tmp_path / "guard.jsonl").write_text(
|
|
116
|
+
'{"outcome":"allow","layer":"judge","engine":"e","latency_ms":NaN}\n'
|
|
117
|
+
)
|
|
118
|
+
e = load(tmp_path)["summary"]["engines"]["e"]
|
|
119
|
+
assert e["calls"] == 1 and e["p50_ms"] is None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_overflowing_cost_total_still_serves(tmp_path):
|
|
123
|
+
import json
|
|
124
|
+
import socket
|
|
125
|
+
import threading
|
|
126
|
+
import urllib.request
|
|
127
|
+
from http.server import ThreadingHTTPServer
|
|
128
|
+
|
|
129
|
+
from judgetap.dashboard.server import make_handler
|
|
130
|
+
|
|
131
|
+
(tmp_path / "guard.jsonl").write_text(
|
|
132
|
+
"\n".join(json.dumps({"outcome": "allow", "cost_usd": 1e308}) for _ in range(2))
|
|
133
|
+
+ "\n"
|
|
134
|
+
)
|
|
135
|
+
with socket.socket() as sock:
|
|
136
|
+
sock.bind(("127.0.0.1", 0))
|
|
137
|
+
port = sock.getsockname()[1]
|
|
138
|
+
srv = ThreadingHTTPServer(("127.0.0.1", port), make_handler(tmp_path, "tok", port))
|
|
139
|
+
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
|
140
|
+
try:
|
|
141
|
+
with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/data") as res:
|
|
142
|
+
assert res.status == 200
|
|
143
|
+
assert json.loads(res.read())["summary"]["cost_usd"] is None
|
|
144
|
+
finally:
|
|
145
|
+
srv.shutdown()
|
|
146
|
+
srv.server_close()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_malformed_calls_field_keeps_legacy_fallback(tmp_path):
|
|
150
|
+
import json
|
|
151
|
+
|
|
152
|
+
from judgetap.dashboard.data import load
|
|
153
|
+
|
|
154
|
+
(tmp_path / "guard.jsonl").write_text(
|
|
155
|
+
json.dumps(
|
|
156
|
+
{
|
|
157
|
+
"id": "a",
|
|
158
|
+
"outcome": "allow",
|
|
159
|
+
"layer": "judge",
|
|
160
|
+
"engine": "e",
|
|
161
|
+
"latency_ms": 12,
|
|
162
|
+
"calls": "x",
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
+ "\n"
|
|
166
|
+
)
|
|
167
|
+
e = load(tmp_path)["summary"]["engines"]["e"]
|
|
168
|
+
assert e["calls"] == 1 and e["p50_ms"] == 12
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_deeply_nested_extra_field_does_not_drop_the_record(tmp_path):
|
|
172
|
+
from judgetap.dashboard.data import load
|
|
173
|
+
|
|
174
|
+
deep = "[" * 5000 + "]" * 5000
|
|
175
|
+
line = '{"id": "a", "outcome": "hold", "layer": "rules", "extra": ' + deep + "}"
|
|
176
|
+
(tmp_path / "guard.jsonl").write_text(line + "\n")
|
|
177
|
+
assert load(tmp_path)["summary"]["total"] == 1
|
|
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
|
|
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
|