jevq 0.1.0__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.
- jevq-0.1.0/.gitignore +60 -0
- jevq-0.1.0/CHANGELOG.md +29 -0
- jevq-0.1.0/LICENSE +21 -0
- jevq-0.1.0/PKG-INFO +112 -0
- jevq-0.1.0/README.md +88 -0
- jevq-0.1.0/docs/examples.md +105 -0
- jevq-0.1.0/docs/jevq.md +175 -0
- jevq-0.1.0/docs/releasing.md +29 -0
- jevq-0.1.0/pyproject.toml +63 -0
- jevq-0.1.0/src/jevq/__init__.py +6 -0
- jevq-0.1.0/src/jevq/cli.py +297 -0
- jevq-0.1.0/src/jevq/client.py +137 -0
jevq-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
|
|
2
|
+
# Beads / Dolt files (added by bd init)
|
|
3
|
+
.dolt/
|
|
4
|
+
*.db
|
|
5
|
+
.beads-credential-key
|
|
6
|
+
.beads/proxieddb/
|
|
7
|
+
*.gate.lock*
|
|
8
|
+
|
|
9
|
+
# BEGIN ortus block=gitignore schema=1 generated-by=ortus@0.4.1.dev103+ga5d6da2
|
|
10
|
+
# IDE
|
|
11
|
+
.idea/
|
|
12
|
+
.vscode/
|
|
13
|
+
*.swp
|
|
14
|
+
*.swo
|
|
15
|
+
*~
|
|
16
|
+
|
|
17
|
+
# Environment files
|
|
18
|
+
.env
|
|
19
|
+
.env.*
|
|
20
|
+
!.env.example
|
|
21
|
+
|
|
22
|
+
# Credentials and secrets
|
|
23
|
+
credentials.json
|
|
24
|
+
*_secret*
|
|
25
|
+
*.pem
|
|
26
|
+
*.key
|
|
27
|
+
|
|
28
|
+
# OS
|
|
29
|
+
.DS_Store
|
|
30
|
+
Thumbs.db
|
|
31
|
+
|
|
32
|
+
# Beads viewer local config
|
|
33
|
+
.bv/
|
|
34
|
+
|
|
35
|
+
# bd runtime lock at the repo root (bd >= 1.2) — tool state, never source
|
|
36
|
+
.beads.gate.lock
|
|
37
|
+
|
|
38
|
+
# Human-decision report (regenerated by `ortus human`)
|
|
39
|
+
HUMAN-TODO.md
|
|
40
|
+
|
|
41
|
+
# Build artifacts and logs
|
|
42
|
+
*.log
|
|
43
|
+
logs/
|
|
44
|
+
|
|
45
|
+
# Cache directories
|
|
46
|
+
.cache/
|
|
47
|
+
|
|
48
|
+
# CodeGraph index — local, machine-specific, and large. Every fresh clone
|
|
49
|
+
# rebuilds it with `codegraph init`; `ortus check` reports it when missing.
|
|
50
|
+
.codegraph/
|
|
51
|
+
|
|
52
|
+
# Python / Node / Go / Rust common build outputs
|
|
53
|
+
__pycache__/
|
|
54
|
+
*.py[cod]
|
|
55
|
+
.venv/
|
|
56
|
+
node_modules/
|
|
57
|
+
dist/
|
|
58
|
+
build/
|
|
59
|
+
target/
|
|
60
|
+
# END ortus block=gitignore
|
jevq-0.1.0/CHANGELOG.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to jevq are recorded here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and jevq uses
|
|
5
|
+
[semantic versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0] - 2026-09-26
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- The jq sidecar filter: read JSONL on stdin and keep each value whose System
|
|
14
|
+
One noul is at or above the threshold, emitting the original bytes.
|
|
15
|
+
- `--score`, which wraps every value as `{"score":<noul>,"value":<original>}`.
|
|
16
|
+
- `--pass`, a byte passthrough with no API call and no key.
|
|
17
|
+
- `-t/--threshold` and `$JEV_THRESHOLD`.
|
|
18
|
+
- `-f/--fields`, which sends only the named keys as state.
|
|
19
|
+
- `--model`, with the default pinned to `jev-1.13.0`, and `$JEV_MODEL`.
|
|
20
|
+
- `-v/--verbose` for end-of-run counts and the answering model; stderr stays
|
|
21
|
+
quiet on success otherwise.
|
|
22
|
+
- Retries with backoff on HTTP 429 and 5xx responses.
|
|
23
|
+
- `samples/` fixtures and offline sample pipe tests against a fake System One.
|
|
24
|
+
- GitHub Actions CI.
|
|
25
|
+
- `scripts/reinstall-cli.sh`.
|
|
26
|
+
- PyPI packaging with a tag-triggered release workflow.
|
|
27
|
+
|
|
28
|
+
[Unreleased]: https://github.com/who/jevq/compare/v0.1.0...HEAD
|
|
29
|
+
[0.1.0]: https://github.com/who/jevq/releases/tag/v0.1.0
|
jevq-0.1.0/LICENSE
ADDED
|
@@ -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.
|
jevq-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
jevq-0.1.0/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# jevq
|
|
2
|
+
|
|
3
|
+
[](https://github.com/who/jevq/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
`jq | jevq | jq`. jq handles structure; jevq reads one JSON value per line from stdin, asks
|
|
6
|
+
Jev (a TypeSafe System One model) a yes/no question about each, and passes through the yeses.
|
|
7
|
+
QUESTION is a yes/no claim about the current value, not a search query.
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
Video: [docs/assets/jevq-demo.mp4](https://github.com/who/jevq/blob/main/docs/assets/jevq-demo.mp4)
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
uv tool install jevq # from PyPI (or: pipx install jevq)
|
|
17
|
+
uv tool install --editable . # from a source checkout
|
|
18
|
+
export TYPESAFE_API_KEY=...
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`scripts/reinstall-cli.sh` forces a rebuild (`--dry-run`, `--no-editable`, `--skip-smoke`).
|
|
22
|
+
|
|
23
|
+
## Options
|
|
24
|
+
|
|
25
|
+
`jevq [options] QUESTION` reads JSONL on stdin. QUESTION is required unless `--pass`.
|
|
26
|
+
|
|
27
|
+
| Flag | Default | Description |
|
|
28
|
+
| --- | --- | --- |
|
|
29
|
+
| `QUESTION` | none | Yes/no claim about each value |
|
|
30
|
+
| `-t N`, `--threshold N` | `0.5` (`$JEV_THRESHOLD`) | Keep values scoring at least N |
|
|
31
|
+
| `-f a,b`, `--fields a,b` | whole value | Send only these keys; output stays the full value |
|
|
32
|
+
| `--score` | off | Emit every value as `{"score":<noul>,"value":<original>}` |
|
|
33
|
+
| `--pass` | off | Emit every value unchanged; no API call, no key |
|
|
34
|
+
| `--model NAME` | `jev-1.13.0` (`$JEV_MODEL`) | Model to ask |
|
|
35
|
+
| `-v`, `--verbose` | off | Print counts and the answering model to stderr |
|
|
36
|
+
| `-h`, `--help` | | Show help and exit |
|
|
37
|
+
|
|
38
|
+
`--score` and `--pass` are mutually exclusive. A flag beats its env var.
|
|
39
|
+
|
|
40
|
+
## Environment
|
|
41
|
+
|
|
42
|
+
| Variable | Default | Description |
|
|
43
|
+
| --- | --- | --- |
|
|
44
|
+
| `TYPESAFE_API_KEY` | none | API key; required except with `--pass` |
|
|
45
|
+
| `JEV_MODEL` | `jev-1.13.0` | Default model |
|
|
46
|
+
| `JEV_BASE_URL` | `https://api.typesafe.ai/v1/systemone` | API base URL |
|
|
47
|
+
| `JEV_THRESHOLD` | `0.5` | Default threshold |
|
|
48
|
+
|
|
49
|
+
## Use cases
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
jq -c '.[] | select(.status == "open")' samples/tickets.json |
|
|
53
|
+
jevq "the customer is asking for a refund" |
|
|
54
|
+
jq -c '{id, subject}'
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Open tickets asking for money back, printed as `{id, subject}`.
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
jq -c 'select(.level == "error")' samples/app.ndjson |
|
|
61
|
+
jevq "this error is caused by a network timeout, not a bug in our code"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Error log lines that look like a timeout rather than a code bug.
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
gh api 'repos/itchyny/gojq/issues?state=open&per_page=100' |
|
|
68
|
+
jq -c '.[] | select(.pull_request | not) | {number, title, body}' |
|
|
69
|
+
jevq --score "reports a crash or wrong output, not a feature request" |
|
|
70
|
+
jq -rs 'sort_by(-.score) | .[:5][] | .value | "\(.number)\t\(.title)"'
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The five most bug-like open issues, as `number<TAB>title`.
|
|
74
|
+
|
|
75
|
+
More examples: [docs/examples.md](https://github.com/who/jevq/blob/main/docs/examples.md)
|
|
76
|
+
|
|
77
|
+
Full contract (output rules, exit codes, HTTP, retries): [docs/jevq.md](https://github.com/who/jevq/blob/main/docs/jevq.md)
|
|
78
|
+
|
|
79
|
+
## Testing
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
uv run pytest -q
|
|
83
|
+
bash tests/samples/run.sh
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`JEVQ_LIVE=1 TYPESAFE_API_KEY=... bash tests/samples/run.sh` runs the sample pipes live.
|
|
87
|
+
|
|
88
|
+
Not supported in v1: `--path`, a `jev()` builtin, concurrency, caching.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# jevq examples
|
|
2
|
+
|
|
3
|
+
More pipes beyond the three in the [README](../README.md). They use the files
|
|
4
|
+
in `samples/`, so run them from the repo root. Output rules and exit codes
|
|
5
|
+
are in [jevq.md](jevq.md).
|
|
6
|
+
|
|
7
|
+
## Dev tools in runtime dependencies
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
jq -c '.dependencies | to_entries[]' samples/package.json |
|
|
11
|
+
jevq "this npm package is a test, lint or build tool, not a runtime library" |
|
|
12
|
+
jq -r .key
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Prints the dependencies that belong in `devDependencies`, one name per line.
|
|
16
|
+
|
|
17
|
+
## Failed logins, cut at 0.7
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
printf '%s\n' \
|
|
21
|
+
'{"level":"warn","msg":"failed login for alice from 10.0.0.5"}' \
|
|
22
|
+
'{"level":"info","msg":"user bob logged in"}' \
|
|
23
|
+
'{"level":"warn","msg":"invalid password for root from 203.0.113.9"}' \
|
|
24
|
+
'{"level":"info","msg":"cache warmed in 120ms"}' |
|
|
25
|
+
jevq --score "this log line records a failed login" |
|
|
26
|
+
jq -c 'select(.score >= 0.7) | .value'
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`--score` wraps each line with its score and jq applies the cutoff.
|
|
30
|
+
|
|
31
|
+
## Send only some fields with `-f`
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
jq -c '.[] | select(.status == "open")' samples/tickets.json |
|
|
35
|
+
jevq -f subject,body "the customer is asking for a refund"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Only `subject` and `body` go to Jev; stdout still gets the full ticket.
|
|
39
|
+
|
|
40
|
+
## Tune the threshold with `-t`
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
jq -c '.[]' samples/tickets.json |
|
|
44
|
+
jevq -t 0.8 "the customer is asking for a refund"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`-t 0.8` keeps confident matches only. To pick a value, look at the scores
|
|
48
|
+
first:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
jq -c '.[]' samples/tickets.json |
|
|
52
|
+
jevq --score "the customer is asking for a refund" |
|
|
53
|
+
jq -r .score |
|
|
54
|
+
sort -n
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Dry run with `--pass`
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
jq -c '.[] | select(.status == "open")' samples/tickets.json |
|
|
61
|
+
jevq --pass |
|
|
62
|
+
wc -l
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Counts the rows a real run would ask about, with no API call and no key.
|
|
66
|
+
`--pass` is also an identity:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
f=$(mktemp) && jq -c '.[]' samples/tickets.json > "$f" &&
|
|
70
|
+
jevq --pass < "$f" |
|
|
71
|
+
cmp - "$f" &&
|
|
72
|
+
echo identical
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Rank GitHub issues offline
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
jq -c '.[] | select(.pull_request | not) | {number, title, body}' samples/gh-issues.json |
|
|
79
|
+
jevq --score "reports a crash or wrong output, not a feature request" |
|
|
80
|
+
jq -rs 'sort_by(-.score) | .[:5][] | .value | "\(.number)\t\(.title)"'
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The README's GitHub pipe with `gh api` swapped for a saved copy.
|
|
84
|
+
|
|
85
|
+
## Counts and model with `-v`
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
jq -c '.[] | select(.status == "open")' samples/tickets.json |
|
|
89
|
+
jevq -v "the customer is asking for a refund" > refunds.jsonl
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
At the end of the run stderr gets:
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
jevq: read 14, emitted 5
|
|
96
|
+
jevq: model: jev-1.13.0
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Tips
|
|
100
|
+
|
|
101
|
+
- Phrase QUESTION so a high score means yes to what you want to keep.
|
|
102
|
+
- To invert, filter on the score: `jevq --score "..." | jq -c 'select(.score < 0.5) | .value'`.
|
|
103
|
+
- Prefilter with jq and slim with `-f` so each row stays small (about 32k tokens max).
|
|
104
|
+
- Scores are not calibrated probabilities; tune `-t` on real data.
|
|
105
|
+
- One API call per row, sequential; the account limit is about 1,200 requests per minute.
|
jevq-0.1.0/docs/jevq.md
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# jevq
|
|
2
|
+
|
|
3
|
+
jevq filters JSON values with a TypeSafe System One `noul` score. It reads
|
|
4
|
+
JSONL on stdin (the output of `jq -c`), makes one System One call per value,
|
|
5
|
+
and writes JSONL on stdout.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
jevq [options] QUESTION
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
QUESTION is a yes/no claim about the current value, for example
|
|
14
|
+
`"the customer is asking for a refund"`. It is not a search query. It is
|
|
15
|
+
required unless `--pass` is given.
|
|
16
|
+
|
|
17
|
+
## Flags
|
|
18
|
+
|
|
19
|
+
| Flag | Meaning |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
| `QUESTION` | Yes/no claim about the current value. |
|
|
22
|
+
| `--score` | Emit every value as `{"score":<noul>,"value":<original>}`. No threshold is applied. |
|
|
23
|
+
| `--pass` | Emit every input value unchanged. No API call and no key required. Cannot be combined with `--score`. |
|
|
24
|
+
| `-t N`, `--threshold N` | Cutoff in [0, 1]. A value is kept when its score is at least N. Default 0.5 or `$JEV_THRESHOLD`. |
|
|
25
|
+
| `-f a,b`, `--fields a,b` | Send only these top-level keys to the model. Output is still the full original value. |
|
|
26
|
+
| `--model NAME` | Model to ask. Default `jev-1.13.0` (pinned) or `$JEV_MODEL`. |
|
|
27
|
+
| `-v`, `--verbose` | Print the end-of-run counts and the answering model to stderr. Combines with every mode. |
|
|
28
|
+
| `-h`, `--help` | Print help and exit. |
|
|
29
|
+
|
|
30
|
+
## Environment
|
|
31
|
+
|
|
32
|
+
| Variable | Meaning |
|
|
33
|
+
| --- | --- |
|
|
34
|
+
| `TYPESAFE_API_KEY` | API key, sent as `Authorization: Bearer <key>`. Required except with `--pass`. |
|
|
35
|
+
| `JEV_MODEL` | Default model when `--model` is not given. Default `jev-1.13.0`. |
|
|
36
|
+
| `JEV_BASE_URL` | Endpoint URL. Default `https://api.typesafe.ai/v1/systemone`. |
|
|
37
|
+
| `JEV_THRESHOLD` | Default threshold when `-t` is not given. Default `0.5`. |
|
|
38
|
+
|
|
39
|
+
## State sent to the model
|
|
40
|
+
|
|
41
|
+
- An object is sent as it is.
|
|
42
|
+
- With `--fields`, an object is cut down to the listed keys that it has. A
|
|
43
|
+
missing key is skipped, not an error.
|
|
44
|
+
- Anything that is not an object (a string, number, array, `true`, `false` or
|
|
45
|
+
`null`) is sent wrapped as `{"value": <value>}`. `--fields` does not apply
|
|
46
|
+
to it.
|
|
47
|
+
|
|
48
|
+
`--fields` only changes what the model sees. stdout always carries the input
|
|
49
|
+
line, so `jq -c '.dependencies | to_entries[]' package.json | jevq -f key ...`
|
|
50
|
+
asks about the package name alone and still emits `{"key":...,"value":...}`.
|
|
51
|
+
|
|
52
|
+
## HTTP request
|
|
53
|
+
|
|
54
|
+
One `POST` to `$JEV_BASE_URL` (default
|
|
55
|
+
`https://api.typesafe.ai/v1/systemone`) per input value, with a 30 second
|
|
56
|
+
timeout and a `User-Agent: jevq/<version>` header. The body is:
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"model": "jev-1.13.0",
|
|
61
|
+
"state": {"id": 7, "subject": "Refund please"},
|
|
62
|
+
"questions": {"q": {"type": "noul", "instructions": "the customer is asking for a refund"}}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The score is read from `answers.q.noul` in the response and must be a finite
|
|
67
|
+
number. A response that is not JSON, lacks `answers.q.noul`, or holds a
|
|
68
|
+
non-number there is an API error. The top-level `model` field of the response,
|
|
69
|
+
when present, is recorded for the `-v` report.
|
|
70
|
+
|
|
71
|
+
Calls run one at a time, in input order. There is no caching.
|
|
72
|
+
|
|
73
|
+
## Retries
|
|
74
|
+
|
|
75
|
+
Transport errors, HTTP 429 and HTTP 5xx are retried, up to 4 attempts in all.
|
|
76
|
+
The wait before each retry is the `Retry-After` header in seconds, capped at
|
|
77
|
+
10 s, or when that header is missing or unusable, 0.5 s, 1 s, then 2 s. Any
|
|
78
|
+
other non-2xx status fails at once. An API failure is never treated as a "no".
|
|
79
|
+
|
|
80
|
+
## Output
|
|
81
|
+
|
|
82
|
+
- Default mode: each value whose score is at least the threshold is written
|
|
83
|
+
exactly as it was read (whitespace around the line trimmed), one per line.
|
|
84
|
+
- `--score`: every value is written as the compact line
|
|
85
|
+
`{"score":<noul>,"value":<original>}`. This is the only way jevq wraps a
|
|
86
|
+
value.
|
|
87
|
+
- `--pass`: every value is written unchanged.
|
|
88
|
+
|
|
89
|
+
Blank input lines are skipped. Output is flushed after every line, so jevq
|
|
90
|
+
works in a streaming pipe, and a downstream reader that closes early (such as
|
|
91
|
+
`| head -1`) ends jevq quietly with status 0.
|
|
92
|
+
|
|
93
|
+
On success jevq writes nothing to stderr. With `-v`/`--verbose` it writes, at
|
|
94
|
+
the end of the run (also after an error):
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
jevq: read N, emitted M
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
After at least one successful API response it also writes the model or models
|
|
101
|
+
that answered, taken from the responses:
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
jevq: model: jev-1.13.0
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Exit codes
|
|
108
|
+
|
|
109
|
+
| Code | Meaning |
|
|
110
|
+
| --- | --- |
|
|
111
|
+
| 0 | Success. |
|
|
112
|
+
| 1 | Runtime failure: an API error (after retries) or an input line that is not valid JSON. jevq stops at that line; values already emitted stay emitted. |
|
|
113
|
+
| 2 | Usage error: bad flags, missing QUESTION, an invalid threshold or `--fields` value, or `TYPESAFE_API_KEY` not set. Reported before stdin is read. |
|
|
114
|
+
|
|
115
|
+
Errors go to stderr prefixed `jevq:`, with or without `-v`, for example
|
|
116
|
+
`jevq: line 3: invalid JSON: ...` or `jevq: line 3: API error: HTTP 401: ...`.
|
|
117
|
+
|
|
118
|
+
## Reinstalling the local tool
|
|
119
|
+
|
|
120
|
+
`uv tool install --editable .` can reuse a cached build or an existing tool
|
|
121
|
+
environment, so the global `jevq` may not match this checkout. After pulling
|
|
122
|
+
or changing code, run:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
scripts/reinstall-cli.sh
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
It works from any directory. It force-reinstalls only the `jevq` uv tool from
|
|
129
|
+
the repo root (`uv tool install --editable --force --reinstall`), then prints
|
|
130
|
+
the version before and after, the source path and install mode, the HEAD short
|
|
131
|
+
sha (with ` (dirty)` when the tree has uncommitted or untracked files), and
|
|
132
|
+
where `jevq` resolves on PATH. It finishes with a smoke test that needs no API
|
|
133
|
+
key: `printf '{"a":1}\n' | jevq --pass` must reproduce its input byte for byte.
|
|
134
|
+
|
|
135
|
+
Flags:
|
|
136
|
+
|
|
137
|
+
- `--dry-run` prints the exact uv command and changes nothing.
|
|
138
|
+
- `--no-editable` installs a snapshot instead of an editable install.
|
|
139
|
+
- `--skip-smoke` skips the `--pass` smoke test.
|
|
140
|
+
- `-h`, `--help` prints usage.
|
|
141
|
+
|
|
142
|
+
The script honours `UV_TOOL_DIR` and `UV_TOOL_BIN_DIR`, so you can install
|
|
143
|
+
into a scratch location without touching your real global tools.
|
|
144
|
+
|
|
145
|
+
## Sample pipes
|
|
146
|
+
|
|
147
|
+
`samples/` holds invented data for four realistic `jq | jevq | jq` pipes: stock
|
|
148
|
+
jq picks or reshapes values, jevq judges each one, and jq post-processes the
|
|
149
|
+
survivors. Run them from the repo root:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
jq -c '.[] | select(.status == "open")' samples/tickets.json | jevq "the customer is asking for a refund" | jq -c '{id, subject}'
|
|
153
|
+
jq -c 'select(.level == "error")' samples/app.ndjson | jevq "this error is caused by a network timeout, not a bug in our code"
|
|
154
|
+
jq -c '.dependencies | to_entries[]' samples/package.json | jevq "this npm package is a test, lint or build tool, not a runtime library" | jq -r '.key'
|
|
155
|
+
jq -c '.[] | select(.pull_request | not) | {number, title, body}' samples/gh-issues.json | jevq --score "reports a crash or wrong output, not a feature request" | jq -rs 'sort_by(-.score) | .[:5][] | .value | "\(.number)\t\(.title)"'
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
`tests/samples/run.sh` runs these pipes end to end, plus checks for `--fields`,
|
|
159
|
+
`--threshold`, `--pass`, one request per value, and a missing key. It starts a
|
|
160
|
+
deterministic fake System One (`tests/samples/fake_systemone.py`, scored by
|
|
161
|
+
`tests/samples/rules.json`) on 127.0.0.1, so it needs no API key and makes no
|
|
162
|
+
network calls. It works from any directory:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
bash tests/samples/run.sh
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
It prints `PASS`, `FAIL` or `SKIP` per case and a final
|
|
169
|
+
`samples: N passed, M failed, K skipped` line, and exits nonzero if any case
|
|
170
|
+
fails. To also run the four pipes against the real endpoint, with looser shape
|
|
171
|
+
checks, set:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
JEVQ_LIVE=1 TYPESAFE_API_KEY=... bash tests/samples/run.sh
|
|
175
|
+
```
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Releasing jevq
|
|
2
|
+
|
|
3
|
+
The version comes from the git tag (hatch-vcs), so there is nothing to bump in
|
|
4
|
+
`pyproject.toml`.
|
|
5
|
+
|
|
6
|
+
## Cut a release
|
|
7
|
+
|
|
8
|
+
1. In `CHANGELOG.md`, move the `[Unreleased]` notes under
|
|
9
|
+
`## [X.Y.Z] - YYYY-MM-DD` and update the compare links at the bottom.
|
|
10
|
+
2. Commit and push to `main`.
|
|
11
|
+
3. Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z`.
|
|
12
|
+
4. `.github/workflows/release.yml` builds the wheel and sdist, publishes them
|
|
13
|
+
to PyPI, and creates a GitHub release whose notes are that version's
|
|
14
|
+
CHANGELOG section.
|
|
15
|
+
|
|
16
|
+
A tag containing `-test` (for example `v0.2.0-test1`) builds and creates the
|
|
17
|
+
GitHub release but skips PyPI. A manual `workflow_dispatch` run only builds.
|
|
18
|
+
|
|
19
|
+
## One-time setup
|
|
20
|
+
|
|
21
|
+
Publishing uses PyPI trusted publishing; there are no tokens or secrets.
|
|
22
|
+
|
|
23
|
+
1. On pypi.org, add a pending publisher with these values:
|
|
24
|
+
- PyPI project name: `jevq`
|
|
25
|
+
- Owner: `who`
|
|
26
|
+
- Repository: `jevq`
|
|
27
|
+
- Workflow: `release.yml`
|
|
28
|
+
- Environment: `pypi`
|
|
29
|
+
2. In the GitHub repository settings, create an environment named `pypi`.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "jevq"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "jq-compatible System One noul filter"
|
|
5
|
+
readme = { file = "README.md", content-type = "text/markdown" }
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
authors = [{ name = "who", email = "githubwho@gmail.com" }]
|
|
8
|
+
keywords = ["jq", "json", "jsonl", "cli", "filter", "llm", "typesafe"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 3 - Alpha",
|
|
11
|
+
"Environment :: Console",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Operating System :: OS Independent",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Topic :: Utilities",
|
|
18
|
+
"Topic :: Text Processing :: Filters",
|
|
19
|
+
]
|
|
20
|
+
requires-python = ">=3.12"
|
|
21
|
+
dependencies = ["httpx>=0.27"]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/who/jevq"
|
|
25
|
+
Repository = "https://github.com/who/jevq"
|
|
26
|
+
Issues = "https://github.com/who/jevq/issues"
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
jevq = "jevq.cli:main"
|
|
30
|
+
|
|
31
|
+
[dependency-groups]
|
|
32
|
+
dev = ["pytest>=8"]
|
|
33
|
+
|
|
34
|
+
[build-system]
|
|
35
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
36
|
+
build-backend = "hatchling.build"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.version]
|
|
39
|
+
source = "vcs"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.version.raw-options]
|
|
42
|
+
fallback_version = "0.0.0"
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.sdist]
|
|
45
|
+
include = ["src/jevq", "README.md", "LICENSE", "CHANGELOG.md", "docs", "pyproject.toml"]
|
|
46
|
+
exclude = [
|
|
47
|
+
".beads",
|
|
48
|
+
"prd",
|
|
49
|
+
".claude",
|
|
50
|
+
".grok",
|
|
51
|
+
".codex",
|
|
52
|
+
".cursor",
|
|
53
|
+
".ortusrc",
|
|
54
|
+
"AGENTS.md",
|
|
55
|
+
"CLAUDE.md",
|
|
56
|
+
"docs/assets",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
[tool.hatch.build.targets.wheel]
|
|
60
|
+
packages = ["src/jevq"]
|
|
61
|
+
|
|
62
|
+
[tool.pytest.ini_options]
|
|
63
|
+
testpaths = ["tests"]
|
|
@@ -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)
|
|
@@ -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
|