jevq 0.1.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.
- jevq/__init__.py +6 -0
- jevq/cli.py +297 -0
- jevq/client.py +137 -0
- jevq-0.1.0.dist-info/METADATA +112 -0
- jevq-0.1.0.dist-info/RECORD +8 -0
- jevq-0.1.0.dist-info/WHEEL +4 -0
- jevq-0.1.0.dist-info/entry_points.txt +2 -0
- jevq-0.1.0.dist-info/licenses/LICENSE +21 -0
jevq/__init__.py
ADDED
jevq/cli.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Command-line entry point for jevq."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
11
|
+
from typing import Any, BinaryIO, Protocol, TextIO
|
|
12
|
+
|
|
13
|
+
from jevq.client import DEFAULT_MODEL, DEFAULT_URL, JevqAPIError, SystemOneClient
|
|
14
|
+
|
|
15
|
+
DESCRIPTION = """\
|
|
16
|
+
Filter JSON values from stdin (JSONL from `jq -c`) with a System One noul.
|
|
17
|
+
One System One call per value. QUESTION is a yes/no claim about the current
|
|
18
|
+
object, not a search query."""
|
|
19
|
+
|
|
20
|
+
EPILOG = """\
|
|
21
|
+
environment:
|
|
22
|
+
TYPESAFE_API_KEY API key (required except --pass)
|
|
23
|
+
JEV_MODEL default model (default jev-1.13.0)
|
|
24
|
+
JEV_BASE_URL API base URL (default https://api.typesafe.ai/v1/systemone)
|
|
25
|
+
JEV_THRESHOLD default threshold (default 0.5)
|
|
26
|
+
|
|
27
|
+
examples:
|
|
28
|
+
jq -c '.[] | select(.status == "open")' tickets.json | jevq "the customer is asking for a refund" | jq -c '{id, subject}'
|
|
29
|
+
jq -c '.[]' tickets.json | jevq --score "the customer is angry" | jq -c 'select(.score >= 0.8) | .value.id'
|
|
30
|
+
jq -c '.[]' tickets.json | jevq --pass"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
34
|
+
parser = argparse.ArgumentParser(
|
|
35
|
+
prog="jevq",
|
|
36
|
+
usage="jevq [options] QUESTION",
|
|
37
|
+
description=DESCRIPTION,
|
|
38
|
+
epilog=EPILOG,
|
|
39
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"question",
|
|
43
|
+
nargs="?",
|
|
44
|
+
metavar="QUESTION",
|
|
45
|
+
help="yes/no claim about the current object",
|
|
46
|
+
)
|
|
47
|
+
mode = parser.add_mutually_exclusive_group()
|
|
48
|
+
mode.add_argument(
|
|
49
|
+
"--score",
|
|
50
|
+
action="store_true",
|
|
51
|
+
help='emit every row as {"score": <noul>, "value": <original>}',
|
|
52
|
+
)
|
|
53
|
+
mode.add_argument(
|
|
54
|
+
"--pass",
|
|
55
|
+
dest="pass_",
|
|
56
|
+
action="store_true",
|
|
57
|
+
help="emit every input value unchanged. No API call. No key required",
|
|
58
|
+
)
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"-t",
|
|
61
|
+
"--threshold",
|
|
62
|
+
metavar="N",
|
|
63
|
+
default=None,
|
|
64
|
+
help="cutoff in [0,1]. Default 0.5 or $JEV_THRESHOLD",
|
|
65
|
+
)
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
"-f",
|
|
68
|
+
"--fields",
|
|
69
|
+
metavar="a,b",
|
|
70
|
+
default=None,
|
|
71
|
+
help="slim state only. Output is still the full original value",
|
|
72
|
+
)
|
|
73
|
+
parser.add_argument(
|
|
74
|
+
"--model",
|
|
75
|
+
metavar="NAME",
|
|
76
|
+
default=None,
|
|
77
|
+
help="default jev-1.13.0 (pinned) or $JEV_MODEL",
|
|
78
|
+
)
|
|
79
|
+
parser.add_argument(
|
|
80
|
+
"-v",
|
|
81
|
+
"--verbose",
|
|
82
|
+
action="store_true",
|
|
83
|
+
help="print end-of-run counts (read/emitted) and the answering model to stderr",
|
|
84
|
+
)
|
|
85
|
+
return parser
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class InputError(Exception):
|
|
89
|
+
"""A non-blank input line that is not exactly one JSON value."""
|
|
90
|
+
|
|
91
|
+
def __init__(self, line_no: int, message: str) -> None:
|
|
92
|
+
super().__init__(f"line {line_no}: invalid JSON: {message}")
|
|
93
|
+
self.line_no = line_no
|
|
94
|
+
self.message = message
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def iter_values(stream: BinaryIO) -> Iterator[tuple[int, bytes, Any]]:
|
|
98
|
+
"""Yield (line_no, raw_bytes, value) per non-blank JSONL line, lazily."""
|
|
99
|
+
for line_no, line in enumerate(stream, start=1):
|
|
100
|
+
raw = line.strip()
|
|
101
|
+
if not raw:
|
|
102
|
+
continue
|
|
103
|
+
try:
|
|
104
|
+
value = json.loads(raw)
|
|
105
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
106
|
+
raise InputError(line_no, str(exc)) from None
|
|
107
|
+
yield line_no, raw, value
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def emit(stdout: BinaryIO, raw: bytes) -> None:
|
|
111
|
+
stdout.write(raw + b"\n")
|
|
112
|
+
stdout.flush()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def format_score_line(score: float, raw: bytes) -> bytes:
|
|
116
|
+
"""The only wrap: compact ``{"score":<noul>,"value":<raw>}`` plus newline."""
|
|
117
|
+
return b'{"score":' + json.dumps(float(score)).encode() + b',"value":' + raw + b"}\n"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def report(stderr: TextIO, read: int, emitted: int) -> None:
|
|
121
|
+
stderr.write(f"jevq: read {read}, emitted {emitted}\n")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def report_model(stderr: TextIO, client: object) -> None:
|
|
125
|
+
"""Name the model(s) that answered, once any System One response succeeded."""
|
|
126
|
+
models = getattr(client, "answered_models", None)
|
|
127
|
+
if models is None or not getattr(client, "responses", 0):
|
|
128
|
+
return
|
|
129
|
+
stderr.write(f"jevq: model: {', '.join(models) if models else 'not reported'}\n")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class UsageError(Exception):
|
|
133
|
+
"""Bad flags, environment or missing key; exit 2 before reading stdin."""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class NoulClient(Protocol):
|
|
137
|
+
def noul(self, state: Any, question: str) -> float: ...
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
ClientFactory = Callable[[str, str, str], NoulClient]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def resolve_threshold(arg: str | None, env: Mapping[str, str]) -> float:
|
|
144
|
+
raw = arg if arg is not None else (env.get("JEV_THRESHOLD") or None)
|
|
145
|
+
if raw is None:
|
|
146
|
+
return 0.5
|
|
147
|
+
try:
|
|
148
|
+
value = float(raw)
|
|
149
|
+
except ValueError:
|
|
150
|
+
value = math.nan
|
|
151
|
+
if not math.isfinite(value) or not 0.0 <= value <= 1.0:
|
|
152
|
+
raise UsageError("threshold must be a number in [0, 1]")
|
|
153
|
+
return value
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def parse_fields(arg: str | None) -> list[str] | None:
|
|
157
|
+
if arg is None:
|
|
158
|
+
return None
|
|
159
|
+
names = [name.strip() for name in arg.split(",")]
|
|
160
|
+
names = [name for name in names if name]
|
|
161
|
+
if not names:
|
|
162
|
+
raise UsageError("--fields needs at least one key name")
|
|
163
|
+
return names
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def build_state(value: Any, fields: list[str] | None) -> Any:
|
|
167
|
+
"""The state sent to System One; emitted output is always the raw line."""
|
|
168
|
+
if not isinstance(value, dict):
|
|
169
|
+
return {"value": value}
|
|
170
|
+
if fields is None:
|
|
171
|
+
return value
|
|
172
|
+
return {k: value[k] for k in fields if k in value}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _default_client_factory(api_key: str, model: str, url: str) -> NoulClient:
|
|
176
|
+
return SystemOneClient(api_key, model, url)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def run(
|
|
180
|
+
argv: list[str] | None,
|
|
181
|
+
stdin: BinaryIO,
|
|
182
|
+
stdout: BinaryIO,
|
|
183
|
+
stderr: TextIO,
|
|
184
|
+
env: Mapping[str, str],
|
|
185
|
+
*,
|
|
186
|
+
client_factory: ClientFactory | None = None,
|
|
187
|
+
) -> int:
|
|
188
|
+
parser = build_parser()
|
|
189
|
+
try:
|
|
190
|
+
args = parser.parse_args(argv)
|
|
191
|
+
except SystemExit as exc:
|
|
192
|
+
code = exc.code
|
|
193
|
+
return code if isinstance(code, int) else (0 if code is None else 2)
|
|
194
|
+
if args.pass_:
|
|
195
|
+
return _run_pass(stdin, stdout, stderr, verbose=args.verbose)
|
|
196
|
+
if not (args.question or "").strip():
|
|
197
|
+
stderr.write("jevq: QUESTION is required unless --pass\n")
|
|
198
|
+
return 2
|
|
199
|
+
try:
|
|
200
|
+
threshold = resolve_threshold(args.threshold, env)
|
|
201
|
+
fields = parse_fields(args.fields)
|
|
202
|
+
api_key = (env.get("TYPESAFE_API_KEY") or "").strip()
|
|
203
|
+
if not api_key:
|
|
204
|
+
raise UsageError("TYPESAFE_API_KEY is not set")
|
|
205
|
+
except UsageError as exc:
|
|
206
|
+
stderr.write(f"jevq: {exc}\n")
|
|
207
|
+
return 2
|
|
208
|
+
model = args.model or env.get("JEV_MODEL") or DEFAULT_MODEL
|
|
209
|
+
url = env.get("JEV_BASE_URL") or DEFAULT_URL
|
|
210
|
+
client = (client_factory or _default_client_factory)(api_key, model, url)
|
|
211
|
+
try:
|
|
212
|
+
return _run_filter(
|
|
213
|
+
client,
|
|
214
|
+
args.question,
|
|
215
|
+
threshold,
|
|
216
|
+
fields,
|
|
217
|
+
stdin,
|
|
218
|
+
stdout,
|
|
219
|
+
stderr,
|
|
220
|
+
score_mode=args.score,
|
|
221
|
+
verbose=args.verbose,
|
|
222
|
+
)
|
|
223
|
+
finally:
|
|
224
|
+
close = getattr(client, "close", None)
|
|
225
|
+
if close is not None:
|
|
226
|
+
close()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _run_filter(
|
|
230
|
+
client: NoulClient,
|
|
231
|
+
question: str,
|
|
232
|
+
threshold: float,
|
|
233
|
+
fields: list[str] | None,
|
|
234
|
+
stdin: BinaryIO,
|
|
235
|
+
stdout: BinaryIO,
|
|
236
|
+
stderr: TextIO,
|
|
237
|
+
*,
|
|
238
|
+
score_mode: bool = False,
|
|
239
|
+
verbose: bool = False,
|
|
240
|
+
) -> int:
|
|
241
|
+
read = emitted = 0
|
|
242
|
+
try:
|
|
243
|
+
for line_no, raw, value in iter_values(stdin):
|
|
244
|
+
read += 1
|
|
245
|
+
try:
|
|
246
|
+
score = client.noul(build_state(value, fields), question)
|
|
247
|
+
except JevqAPIError as exc:
|
|
248
|
+
stderr.write(f"jevq: line {line_no}: API error: {exc}\n")
|
|
249
|
+
if verbose:
|
|
250
|
+
report(stderr, read, emitted)
|
|
251
|
+
report_model(stderr, client)
|
|
252
|
+
return 1
|
|
253
|
+
if score_mode:
|
|
254
|
+
stdout.write(format_score_line(score, raw))
|
|
255
|
+
stdout.flush()
|
|
256
|
+
emitted += 1
|
|
257
|
+
elif score >= threshold:
|
|
258
|
+
emit(stdout, raw)
|
|
259
|
+
emitted += 1
|
|
260
|
+
except InputError as exc:
|
|
261
|
+
stderr.write(f"jevq: {exc}\n")
|
|
262
|
+
if verbose:
|
|
263
|
+
report(stderr, read, emitted)
|
|
264
|
+
report_model(stderr, client)
|
|
265
|
+
return 1
|
|
266
|
+
if verbose:
|
|
267
|
+
report(stderr, read, emitted)
|
|
268
|
+
report_model(stderr, client)
|
|
269
|
+
return 0
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _run_pass(stdin: BinaryIO, stdout: BinaryIO, stderr: TextIO, *, verbose: bool = False) -> int:
|
|
273
|
+
read = emitted = 0
|
|
274
|
+
try:
|
|
275
|
+
for _line_no, raw, _value in iter_values(stdin):
|
|
276
|
+
read += 1
|
|
277
|
+
emit(stdout, raw)
|
|
278
|
+
emitted += 1
|
|
279
|
+
except InputError as exc:
|
|
280
|
+
stderr.write(f"jevq: {exc}\n")
|
|
281
|
+
if verbose:
|
|
282
|
+
report(stderr, read, emitted)
|
|
283
|
+
return 1
|
|
284
|
+
if verbose:
|
|
285
|
+
report(stderr, read, emitted)
|
|
286
|
+
return 0
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def main() -> None:
|
|
290
|
+
try:
|
|
291
|
+
code = run(sys.argv[1:], sys.stdin.buffer, sys.stdout.buffer, sys.stderr, os.environ)
|
|
292
|
+
except BrokenPipeError:
|
|
293
|
+
# Downstream closed early (e.g. `| head -1`): silence the flush at exit.
|
|
294
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
295
|
+
os.dup2(devnull, sys.stdout.fileno())
|
|
296
|
+
sys.exit(0)
|
|
297
|
+
sys.exit(code)
|
jevq/client.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""httpx client for the TypeSafe System One ``noul`` endpoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from jevq import __version__
|
|
13
|
+
|
|
14
|
+
DEFAULT_URL = "https://api.typesafe.ai/v1/systemone"
|
|
15
|
+
DEFAULT_MODEL = "jev-1.13.0"
|
|
16
|
+
|
|
17
|
+
_MAX_RETRY_AFTER = 10.0
|
|
18
|
+
_BASE_BACKOFF = 0.5
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class JevqAPIError(Exception):
|
|
22
|
+
"""System One call failed; never a "no" answer."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _retry_after(resp: httpx.Response | None) -> float | None:
|
|
26
|
+
if resp is None:
|
|
27
|
+
return None
|
|
28
|
+
raw = resp.headers.get("Retry-After")
|
|
29
|
+
if raw is None:
|
|
30
|
+
return None
|
|
31
|
+
try:
|
|
32
|
+
value = float(raw)
|
|
33
|
+
except ValueError:
|
|
34
|
+
return None
|
|
35
|
+
if not math.isfinite(value) or value < 0:
|
|
36
|
+
return None
|
|
37
|
+
return min(value, _MAX_RETRY_AFTER)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_retryable(status: int) -> bool:
|
|
41
|
+
return status == 429 or 500 <= status <= 599
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SystemOneClient:
|
|
45
|
+
"""Ask System One one ``noul`` question about one state per call."""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
api_key: str,
|
|
50
|
+
model: str = DEFAULT_MODEL,
|
|
51
|
+
url: str = DEFAULT_URL,
|
|
52
|
+
*,
|
|
53
|
+
timeout: float = 30.0,
|
|
54
|
+
max_retries: int = 3,
|
|
55
|
+
transport: httpx.BaseTransport | None = None,
|
|
56
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
57
|
+
) -> None:
|
|
58
|
+
self.model = model
|
|
59
|
+
self.url = url
|
|
60
|
+
self.max_retries = max_retries
|
|
61
|
+
self._sleep = sleep
|
|
62
|
+
self.responses = 0
|
|
63
|
+
self.answered_models: list[str] = []
|
|
64
|
+
self._http = httpx.Client(
|
|
65
|
+
timeout=timeout,
|
|
66
|
+
transport=transport,
|
|
67
|
+
headers={
|
|
68
|
+
"Authorization": f"Bearer {api_key}",
|
|
69
|
+
"User-Agent": f"jevq/{__version__}",
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def build_body(self, state: Any, question: str) -> dict:
|
|
74
|
+
return {
|
|
75
|
+
"model": self.model,
|
|
76
|
+
"state": state,
|
|
77
|
+
"questions": {"q": {"type": "noul", "instructions": question}},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
def noul(self, state: Any, question: str) -> float:
|
|
81
|
+
body = self.build_body(state, question)
|
|
82
|
+
attempts = self.max_retries + 1
|
|
83
|
+
last_error = ""
|
|
84
|
+
for attempt in range(attempts):
|
|
85
|
+
resp: httpx.Response | None = None
|
|
86
|
+
try:
|
|
87
|
+
resp = self._http.post(self.url, json=body)
|
|
88
|
+
except httpx.TransportError as exc:
|
|
89
|
+
last_error = f"transport error: {type(exc).__name__}"
|
|
90
|
+
else:
|
|
91
|
+
if resp.is_success:
|
|
92
|
+
score = _parse_noul(resp)
|
|
93
|
+
self._record_model(resp)
|
|
94
|
+
return score
|
|
95
|
+
if not _is_retryable(resp.status_code):
|
|
96
|
+
raise JevqAPIError(f"HTTP {resp.status_code}: {resp.text[:200]}")
|
|
97
|
+
last_error = f"HTTP {resp.status_code}"
|
|
98
|
+
if attempt < self.max_retries:
|
|
99
|
+
delay = _retry_after(resp)
|
|
100
|
+
if delay is None:
|
|
101
|
+
delay = _BASE_BACKOFF * 2**attempt
|
|
102
|
+
self._sleep(delay)
|
|
103
|
+
raise JevqAPIError(f"{last_error} after {attempts} attempts")
|
|
104
|
+
|
|
105
|
+
def _record_model(self, resp: httpx.Response) -> None:
|
|
106
|
+
"""Note the answering model from the response's top-level ``model``."""
|
|
107
|
+
self.responses += 1
|
|
108
|
+
model = resp.json().get("model")
|
|
109
|
+
if isinstance(model, str) and model and model not in self.answered_models:
|
|
110
|
+
self.answered_models.append(model)
|
|
111
|
+
|
|
112
|
+
def close(self) -> None:
|
|
113
|
+
self._http.close()
|
|
114
|
+
|
|
115
|
+
def __enter__(self) -> SystemOneClient:
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
119
|
+
self.close()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _parse_noul(resp: httpx.Response) -> float:
|
|
123
|
+
try:
|
|
124
|
+
value = resp.json()["answers"]["q"]["noul"]
|
|
125
|
+
except ValueError as exc:
|
|
126
|
+
raise JevqAPIError(f"invalid response: not JSON ({exc})") from None
|
|
127
|
+
except (KeyError, TypeError, IndexError):
|
|
128
|
+
raise JevqAPIError("invalid response: missing answers.q.noul") from None
|
|
129
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
130
|
+
raise JevqAPIError(f"invalid response: noul is {type(value).__name__}, not a number")
|
|
131
|
+
try:
|
|
132
|
+
score = float(value)
|
|
133
|
+
except OverflowError:
|
|
134
|
+
score = math.inf
|
|
135
|
+
if not math.isfinite(score):
|
|
136
|
+
raise JevqAPIError("invalid response: noul is not finite")
|
|
137
|
+
return score
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: jevq
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: jq-compatible System One noul filter
|
|
5
|
+
Project-URL: Homepage, https://github.com/who/jevq
|
|
6
|
+
Project-URL: Repository, https://github.com/who/jevq
|
|
7
|
+
Project-URL: Issues, https://github.com/who/jevq/issues
|
|
8
|
+
Author-email: who <githubwho@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: cli,filter,jq,json,jsonl,llm,typesafe
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Text Processing :: Filters
|
|
20
|
+
Classifier: Topic :: Utilities
|
|
21
|
+
Requires-Python: >=3.12
|
|
22
|
+
Requires-Dist: httpx>=0.27
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# jevq
|
|
26
|
+
|
|
27
|
+
[](https://github.com/who/jevq/actions/workflows/ci.yml)
|
|
28
|
+
|
|
29
|
+
`jq | jevq | jq`. jq handles structure; jevq reads one JSON value per line from stdin, asks
|
|
30
|
+
Jev (a TypeSafe System One model) a yes/no question about each, and passes through the yeses.
|
|
31
|
+
QUESTION is a yes/no claim about the current value, not a search query.
|
|
32
|
+
|
|
33
|
+

|
|
34
|
+
|
|
35
|
+
Video: [docs/assets/jevq-demo.mp4](https://github.com/who/jevq/blob/main/docs/assets/jevq-demo.mp4)
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
uv tool install jevq # from PyPI (or: pipx install jevq)
|
|
41
|
+
uv tool install --editable . # from a source checkout
|
|
42
|
+
export TYPESAFE_API_KEY=...
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`scripts/reinstall-cli.sh` forces a rebuild (`--dry-run`, `--no-editable`, `--skip-smoke`).
|
|
46
|
+
|
|
47
|
+
## Options
|
|
48
|
+
|
|
49
|
+
`jevq [options] QUESTION` reads JSONL on stdin. QUESTION is required unless `--pass`.
|
|
50
|
+
|
|
51
|
+
| Flag | Default | Description |
|
|
52
|
+
| --- | --- | --- |
|
|
53
|
+
| `QUESTION` | none | Yes/no claim about each value |
|
|
54
|
+
| `-t N`, `--threshold N` | `0.5` (`$JEV_THRESHOLD`) | Keep values scoring at least N |
|
|
55
|
+
| `-f a,b`, `--fields a,b` | whole value | Send only these keys; output stays the full value |
|
|
56
|
+
| `--score` | off | Emit every value as `{"score":<noul>,"value":<original>}` |
|
|
57
|
+
| `--pass` | off | Emit every value unchanged; no API call, no key |
|
|
58
|
+
| `--model NAME` | `jev-1.13.0` (`$JEV_MODEL`) | Model to ask |
|
|
59
|
+
| `-v`, `--verbose` | off | Print counts and the answering model to stderr |
|
|
60
|
+
| `-h`, `--help` | | Show help and exit |
|
|
61
|
+
|
|
62
|
+
`--score` and `--pass` are mutually exclusive. A flag beats its env var.
|
|
63
|
+
|
|
64
|
+
## Environment
|
|
65
|
+
|
|
66
|
+
| Variable | Default | Description |
|
|
67
|
+
| --- | --- | --- |
|
|
68
|
+
| `TYPESAFE_API_KEY` | none | API key; required except with `--pass` |
|
|
69
|
+
| `JEV_MODEL` | `jev-1.13.0` | Default model |
|
|
70
|
+
| `JEV_BASE_URL` | `https://api.typesafe.ai/v1/systemone` | API base URL |
|
|
71
|
+
| `JEV_THRESHOLD` | `0.5` | Default threshold |
|
|
72
|
+
|
|
73
|
+
## Use cases
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
jq -c '.[] | select(.status == "open")' samples/tickets.json |
|
|
77
|
+
jevq "the customer is asking for a refund" |
|
|
78
|
+
jq -c '{id, subject}'
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Open tickets asking for money back, printed as `{id, subject}`.
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
jq -c 'select(.level == "error")' samples/app.ndjson |
|
|
85
|
+
jevq "this error is caused by a network timeout, not a bug in our code"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Error log lines that look like a timeout rather than a code bug.
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
gh api 'repos/itchyny/gojq/issues?state=open&per_page=100' |
|
|
92
|
+
jq -c '.[] | select(.pull_request | not) | {number, title, body}' |
|
|
93
|
+
jevq --score "reports a crash or wrong output, not a feature request" |
|
|
94
|
+
jq -rs 'sort_by(-.score) | .[:5][] | .value | "\(.number)\t\(.title)"'
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The five most bug-like open issues, as `number<TAB>title`.
|
|
98
|
+
|
|
99
|
+
More examples: [docs/examples.md](https://github.com/who/jevq/blob/main/docs/examples.md)
|
|
100
|
+
|
|
101
|
+
Full contract (output rules, exit codes, HTTP, retries): [docs/jevq.md](https://github.com/who/jevq/blob/main/docs/jevq.md)
|
|
102
|
+
|
|
103
|
+
## Testing
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
uv run pytest -q
|
|
107
|
+
bash tests/samples/run.sh
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`JEVQ_LIVE=1 TYPESAFE_API_KEY=... bash tests/samples/run.sh` runs the sample pipes live.
|
|
111
|
+
|
|
112
|
+
Not supported in v1: `--path`, a `jev()` builtin, concurrency, caching.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
jevq/__init__.py,sha256=Wnlf-_59Qwn66xZUiA2oyHLtF7izD-gZyHiaCnvG6Uo,164
|
|
2
|
+
jevq/cli.py,sha256=33Ugf3KDG4RGu14qN1zaLVE8Nze6xDSGi47A4Uo6rTY,9226
|
|
3
|
+
jevq/client.py,sha256=qIzzG_MMjMdp7aBs4vCePkJehVoH2J7gDVpgkC9pNIw,4348
|
|
4
|
+
jevq-0.1.0.dist-info/METADATA,sha256=b88CHQgeltL7hqrKbWasf6CoUmyNpSARjTy9zjl4KnM,4116
|
|
5
|
+
jevq-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
6
|
+
jevq-0.1.0.dist-info/entry_points.txt,sha256=Ce5-FLbUDQsGnzPZm-JlOnIXCwz2xISYoR1R8_zEAyo,39
|
|
7
|
+
jevq-0.1.0.dist-info/licenses/LICENSE,sha256=7g1B9RFIOYnUT-dx6FnYMn6HEA9bqDDEVLtji__nRLE,1060
|
|
8
|
+
jevq-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 who
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|