juried 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.
- juried-0.1.0/.env.example +61 -0
- juried-0.1.0/.gitignore +13 -0
- juried-0.1.0/CONTRIBUTING.md +37 -0
- juried-0.1.0/LICENCE +21 -0
- juried-0.1.0/Makefile +21 -0
- juried-0.1.0/PKG-INFO +182 -0
- juried-0.1.0/README.md +164 -0
- juried-0.1.0/docs/report.png +0 -0
- juried-0.1.0/examples/faq-bot/acceptance.md +16 -0
- juried-0.1.0/examples/faq-bot/juried.toml +14 -0
- juried-0.1.0/examples/faq-bot/scenarios/handwritten.yaml +37 -0
- juried-0.1.0/examples/faq-bot/server.py +79 -0
- juried-0.1.0/juried.png +0 -0
- juried-0.1.0/pyproject.toml +52 -0
- juried-0.1.0/src/juried/__init__.py +1 -0
- juried-0.1.0/src/juried/cache.py +48 -0
- juried-0.1.0/src/juried/cli.py +208 -0
- juried-0.1.0/src/juried/config.py +154 -0
- juried-0.1.0/src/juried/criteria.py +62 -0
- juried-0.1.0/src/juried/generate.py +115 -0
- juried-0.1.0/src/juried/judge/__init__.py +33 -0
- juried-0.1.0/src/juried/judge/anthropic.py +50 -0
- juried-0.1.0/src/juried/judge/base.py +196 -0
- juried-0.1.0/src/juried/judge/openai.py +44 -0
- juried-0.1.0/src/juried/judge/prompts.py +102 -0
- juried-0.1.0/src/juried/judge/stub.py +52 -0
- juried-0.1.0/src/juried/pytest_plugin.py +279 -0
- juried-0.1.0/src/juried/report/__init__.py +37 -0
- juried-0.1.0/src/juried/report/html.py +26 -0
- juried-0.1.0/src/juried/report/json.py +83 -0
- juried-0.1.0/src/juried/report/logo.png +0 -0
- juried-0.1.0/src/juried/report/template.html +126 -0
- juried-0.1.0/src/juried/runner.py +253 -0
- juried-0.1.0/src/juried/scenarios.py +120 -0
- juried-0.1.0/src/juried/stats.py +33 -0
- juried-0.1.0/src/juried/targets/__init__.py +4 -0
- juried-0.1.0/src/juried/targets/base.py +21 -0
- juried-0.1.0/src/juried/targets/http.py +111 -0
- juried-0.1.0/src/juried/transport.py +48 -0
- juried-0.1.0/tests/conftest.py +52 -0
- juried-0.1.0/tests/test_cli.py +116 -0
- juried-0.1.0/tests/test_config.py +106 -0
- juried-0.1.0/tests/test_criteria.py +48 -0
- juried-0.1.0/tests/test_generate.py +59 -0
- juried-0.1.0/tests/test_judge.py +264 -0
- juried-0.1.0/tests/test_plugin.py +205 -0
- juried-0.1.0/tests/test_report.py +163 -0
- juried-0.1.0/tests/test_runner.py +221 -0
- juried-0.1.0/tests/test_scenarios.py +122 -0
- juried-0.1.0/tests/test_stats.py +51 -0
- juried-0.1.0/tests/test_target.py +161 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Environment variables read by juried.
|
|
2
|
+
#
|
|
3
|
+
# juried never reads this file itself: it only reads the process environment. Copy it
|
|
4
|
+
# to .env, fill in the values you need, then load it into your shell before running:
|
|
5
|
+
#
|
|
6
|
+
# set -a; source .env; set +a
|
|
7
|
+
#
|
|
8
|
+
# or let direnv, your CI secrets store or your shell profile export them.
|
|
9
|
+
|
|
10
|
+
# ---------------------------------------------------------------------------
|
|
11
|
+
# Judge and generation providers. Only the key for the provider named in
|
|
12
|
+
# [judge] (or [generate]) of juried.toml is needed. The stub provider needs none.
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
ANTHROPIC_API_KEY=
|
|
15
|
+
OPENAI_API_KEY=
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Target credentials. Header values in [target] of juried.toml may reference
|
|
19
|
+
# any variable as ${NAME}; a missing variable stops the run with an error.
|
|
20
|
+
# Name these to match your own config, for example:
|
|
21
|
+
# headers = { Authorization = "Bearer ${STAGING_TOKEN}" }
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
STAGING_TOKEN=
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Overrides for juried.toml. Any scalar key can be set as JURIED_<SECTION>_<KEY>
|
|
27
|
+
# and takes precedence over the file. Sections: target, criteria, run, judge,
|
|
28
|
+
# generate. Nested values such as target.headers and target.body cannot be
|
|
29
|
+
# overridden this way. Leave unset to use the values in juried.toml.
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
# [target]
|
|
33
|
+
#JURIED_TARGET_URL=https://staging.example.com/api/chat
|
|
34
|
+
#JURIED_TARGET_METHOD=POST
|
|
35
|
+
#JURIED_TARGET_RESPONSE_PATH=reply
|
|
36
|
+
#JURIED_TARGET_TIMEOUT_SECONDS=30
|
|
37
|
+
#JURIED_TARGET_RETRIES=3
|
|
38
|
+
|
|
39
|
+
# [criteria]
|
|
40
|
+
#JURIED_CRITERIA_FILE=acceptance.md
|
|
41
|
+
#JURIED_CRITERIA_SCENARIOS_DIR=scenarios
|
|
42
|
+
|
|
43
|
+
# [run]
|
|
44
|
+
#JURIED_RUN_RUNS=10
|
|
45
|
+
#JURIED_RUN_THRESHOLD=0.7
|
|
46
|
+
#JURIED_RUN_CONCURRENCY=4
|
|
47
|
+
#JURIED_RUN_CACHE_DIR=.juried
|
|
48
|
+
#JURIED_RUN_REPORT_DIR=reports
|
|
49
|
+
|
|
50
|
+
# [judge] provider is anthropic, openai or stub
|
|
51
|
+
#JURIED_JUDGE_PROVIDER=anthropic
|
|
52
|
+
#JURIED_JUDGE_MODEL=claude-sonnet-5
|
|
53
|
+
#JURIED_JUDGE_TEMPERATURE=0.0
|
|
54
|
+
#JURIED_JUDGE_MAX_TOKENS=2048
|
|
55
|
+
#JURIED_JUDGE_BASE_URL=
|
|
56
|
+
|
|
57
|
+
# [generate] provider and model default to the [judge] values
|
|
58
|
+
#JURIED_GENERATE_PROVIDER=
|
|
59
|
+
#JURIED_GENERATE_MODEL=
|
|
60
|
+
#JURIED_GENERATE_SCENARIOS_PER_CRITERION=4
|
|
61
|
+
#JURIED_GENERATE_MAX_TOKENS=4096
|
juried-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
## Setup
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
uv venv --python 3.12 .venv && source .venv/bin/activate
|
|
7
|
+
uv pip install -e ".[dev]"
|
|
8
|
+
make check
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`make check` runs ruff, mypy (strict) and the test suite. All three must be clean before
|
|
12
|
+
a change is ready. `make example` starts the fake FAQ bot, generates scenarios and runs
|
|
13
|
+
them end to end without API keys.
|
|
14
|
+
|
|
15
|
+
## Layout
|
|
16
|
+
|
|
17
|
+
- `src/juried/config.py`, `criteria.py`, `scenarios.py`: models and file parsing.
|
|
18
|
+
- `src/juried/targets/`: the system under test. Stage 1 has an HTTP endpoint target.
|
|
19
|
+
- `src/juried/judge/`: providers. `base.py` holds the interface and the shared judge and
|
|
20
|
+
generation logic, `prompts.py` the pinned prompts, `stub.py` a key free provider.
|
|
21
|
+
- `src/juried/runner.py`: repeated runs, concurrency and caching.
|
|
22
|
+
- `src/juried/pytest_plugin.py`: collection of YAML scenarios as pytest items.
|
|
23
|
+
- `src/juried/report/`: JSON and single file HTML output.
|
|
24
|
+
- `src/juried/cli.py`: `init`, `generate` and `run`.
|
|
25
|
+
|
|
26
|
+
Extension points for later stages: a new `Target` implementation for UI driving, a new
|
|
27
|
+
`Provider` for other judges, a `turns` field on `Scenario` for multi turn conversations,
|
|
28
|
+
and new scenario kinds for adversarial generation.
|
|
29
|
+
|
|
30
|
+
## House rules
|
|
31
|
+
|
|
32
|
+
- UK English in code, comments, docs and output
|
|
33
|
+
- Comment only what the code cannot say. No docstrings that restate a name.
|
|
34
|
+
- Keep dependencies to the current list unless there is a strong reason.
|
|
35
|
+
- Type hints on public functions; ruff and mypy stay clean without disabling rules.
|
|
36
|
+
- Bump `PROMPT_VERSION` in `judge/prompts.py` whenever a prompt changes, so cached
|
|
37
|
+
verdicts from the old prompt are not reused.
|
juried-0.1.0/LICENCE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 juried contributors
|
|
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.
|
juried-0.1.0/Makefile
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
PYTHON ?= python
|
|
2
|
+
|
|
3
|
+
.PHONY: check lint type test example
|
|
4
|
+
|
|
5
|
+
check: lint type test
|
|
6
|
+
|
|
7
|
+
lint:
|
|
8
|
+
$(PYTHON) -m ruff check src tests examples
|
|
9
|
+
$(PYTHON) -m ruff format --check src tests examples
|
|
10
|
+
|
|
11
|
+
type:
|
|
12
|
+
$(PYTHON) -m mypy
|
|
13
|
+
|
|
14
|
+
test:
|
|
15
|
+
$(PYTHON) -m pytest
|
|
16
|
+
|
|
17
|
+
example:
|
|
18
|
+
cd examples/faq-bot && $(PYTHON) server.py & \
|
|
19
|
+
sleep 1; \
|
|
20
|
+
cd examples/faq-bot && juried generate && juried run; status=$$?; \
|
|
21
|
+
kill %1; exit $$status
|
juried-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: juried
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Acceptance testing for LLM features, built for QA teams.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENCE
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Requires-Dist: httpx>=0.27
|
|
9
|
+
Requires-Dist: jinja2>=3.1
|
|
10
|
+
Requires-Dist: pydantic>=2.6
|
|
11
|
+
Requires-Dist: pytest>=8.0
|
|
12
|
+
Requires-Dist: pyyaml>=6.0
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
15
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
16
|
+
Requires-Dist: types-pyyaml; extra == 'dev'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
<p align="center">
|
|
20
|
+
<img src="https://raw.githubusercontent.com/jch1887/juried/main/juried.png" alt="juried" width="360">
|
|
21
|
+
<br>
|
|
22
|
+
Acceptance testing for LLM features, built for QA teams.
|
|
23
|
+
</p>
|
|
24
|
+
|
|
25
|
+
juried treats your LLM powered feature as a black box behind an HTTP endpoint. You write
|
|
26
|
+
acceptance criteria in plain English, juried generates test scenarios from them, runs each
|
|
27
|
+
scenario repeatedly, has a pinned LLM judge mark every response, and reports pass rates
|
|
28
|
+
with confidence intervals. Scenarios are ordinary pytest tests, so `-k`, `-x`, markers
|
|
29
|
+
and `--junitxml` all work and the results fit an existing CI job. The name says how it
|
|
30
|
+
works: the feature goes before a jury of repeated runs and only passes when the panel's
|
|
31
|
+
verdict holds.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
pip install juried
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Python 3.12 or later. Judges read `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` from the
|
|
40
|
+
environment; nothing else is ever read from disk.
|
|
41
|
+
|
|
42
|
+
## Three commands
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
juried init # writes juried.toml and an example acceptance.md
|
|
46
|
+
juried generate # turns each criterion into scenarios/generated/<criterion>.yaml
|
|
47
|
+
juried run # runs every scenario N times under pytest and writes the report
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`juried run` accepts pytest arguments after its own, for example
|
|
51
|
+
`juried run --runs 20 -k refunds -x --junitxml=out.xml`. Generation is a one off step:
|
|
52
|
+
review and edit the generated YAML, commit it, and `run` never touches it.
|
|
53
|
+
|
|
54
|
+
## Configuration
|
|
55
|
+
|
|
56
|
+
`juried.toml` lives at the repo root. Any key can be overridden with an environment
|
|
57
|
+
variable named `JURIED_<SECTION>_<KEY>`, such as `JURIED_RUN_RUNS=20`. `.env.example`
|
|
58
|
+
lists every variable juried reads, including the provider keys and the overrides;
|
|
59
|
+
copy it to `.env` and export it from your shell, since juried does not load it itself.
|
|
60
|
+
|
|
61
|
+
```toml
|
|
62
|
+
[target]
|
|
63
|
+
url = "https://staging.example.com/api/chat"
|
|
64
|
+
headers = { Authorization = "Bearer ${STAGING_TOKEN}" }
|
|
65
|
+
body = { message = "{{message}}", history = "{{history}}" }
|
|
66
|
+
response_path = "choices.0.message.content"
|
|
67
|
+
|
|
68
|
+
[run]
|
|
69
|
+
runs = 10 # attempts per scenario
|
|
70
|
+
threshold = 0.7 # gate on the lower bound of the Wilson 95% interval
|
|
71
|
+
concurrency = 4
|
|
72
|
+
|
|
73
|
+
[judge]
|
|
74
|
+
provider = "anthropic" # anthropic, openai or stub
|
|
75
|
+
model = "claude-sonnet-5" # pinned and recorded with every verdict
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Set `temperature` under `[judge]` only for a model that accepts it. `claude-sonnet-5` rejects
|
|
79
|
+
the parameter, so the example leaves it out; when it is unset nothing is sent and the report
|
|
80
|
+
says so.
|
|
81
|
+
|
|
82
|
+
`{{message}}` is replaced with the scenario message; a value of exactly `"{{history}}"`
|
|
83
|
+
becomes the earlier turns as a list of `{role, content}` objects. `response_path` is a
|
|
84
|
+
dotted path into the JSON reply.
|
|
85
|
+
|
|
86
|
+
## Criteria and scenarios
|
|
87
|
+
|
|
88
|
+
Criteria are `##` headings in a Markdown file. The heading becomes a stable id; add
|
|
89
|
+
`{#id}` to fix it explicitly.
|
|
90
|
+
|
|
91
|
+
```markdown
|
|
92
|
+
## Refund policy
|
|
93
|
+
The bot explains that any item can be returned within 14 days of delivery for a full
|
|
94
|
+
refund. It must state the 14 day window.
|
|
95
|
+
|
|
96
|
+
## Unknown questions {#unknown}
|
|
97
|
+
When the bot cannot answer it says so and gives help@example.com.
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Generated and hand written scenarios share one YAML shape. Hand written files go
|
|
101
|
+
anywhere under `scenarios/`; `runs` and `threshold` may be set per scenario.
|
|
102
|
+
|
|
103
|
+
```yaml
|
|
104
|
+
criterion: refund-policy
|
|
105
|
+
scenarios:
|
|
106
|
+
- name: Asks how to get money back
|
|
107
|
+
kind: happy_path
|
|
108
|
+
message: I want my money back on a jumper that does not fit.
|
|
109
|
+
expected: States the 14 day return window and that the refund is full.
|
|
110
|
+
- name: Follows up after a delivery answer
|
|
111
|
+
kind: edge_case
|
|
112
|
+
history:
|
|
113
|
+
- role: user
|
|
114
|
+
content: Can you tell me about delivery?
|
|
115
|
+
- role: assistant
|
|
116
|
+
content: Standard delivery takes 3 to 5 working days.
|
|
117
|
+
message: and refunds?
|
|
118
|
+
expected: Explains the 14 day refund window without repeating the delivery answer.
|
|
119
|
+
runs: 20
|
|
120
|
+
threshold: 0.8
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
A phrase in double quotes inside `expected` must appear in the response word for word,
|
|
124
|
+
ignoring case. Text outside quotes is judged on meaning. With
|
|
125
|
+
`expected: Says returns are accepted within "14 days" for a full refund`, a response saying
|
|
126
|
+
"you have 14 days and get every penny back" passes, while "a fortnight for a full refund"
|
|
127
|
+
fails because `14 days` is missing. The stub judge applies the same rule.
|
|
128
|
+
|
|
129
|
+
## How a scenario passes
|
|
130
|
+
|
|
131
|
+
Each scenario runs `runs` times. The judge marks each response pass or fail with a one
|
|
132
|
+
line reason, using a fixed prompt and the configured temperature, if any. juried computes the
|
|
133
|
+
pass rate and its
|
|
134
|
+
Wilson 95% interval, and the scenario passes when the lower bound meets `threshold`. With
|
|
135
|
+
10 runs a perfect score gives a lower bound of 0.72, so the default threshold is 0.7; a
|
|
136
|
+
stricter threshold needs more runs, and juried warns when a gate can never pass.
|
|
137
|
+
|
|
138
|
+
A failing gate is a normal pytest failure that shows the pass rate, the interval, the
|
|
139
|
+
threshold and the first failing transcript with the judge's reason. An HTTP error from
|
|
140
|
+
your endpoint is reported as a transport error, separately from a judge fail. Responses
|
|
141
|
+
and verdicts are cached in `.juried/` by content hash, so re-runs during development are
|
|
142
|
+
cheap; pass `--no-cache` to skip it. Every verdict is appended to `.juried/verdicts.jsonl`
|
|
143
|
+
with the judge model and timestamp.
|
|
144
|
+
|
|
145
|
+
## The report
|
|
146
|
+
|
|
147
|
+
After a run juried writes `reports/juried-report.html` and `reports/juried-report.json`.
|
|
148
|
+
The HTML is a single self contained file with no scripts. It opens with the totals, then
|
|
149
|
+
lists each acceptance criterion with its description, a table of its scenarios showing
|
|
150
|
+
passes, pass rate, interval, threshold, response latency and gate result, and beneath the
|
|
151
|
+
table each scenario's message, expectation and every failing run with the response and
|
|
152
|
+
the judge's reason. Criteria with no scenarios are called out so coverage gaps are
|
|
153
|
+
visible. The JSON file holds the same structure plus every attempt, for anyone who wants
|
|
154
|
+
to chart trends.
|
|
155
|
+
|
|
156
|
+
<img src="https://raw.githubusercontent.com/jch1887/juried/main/docs/report.png" alt="juried report showing three scenarios, one at 7 of 10 failing because its lower bound is below the threshold" width="900">
|
|
157
|
+
|
|
158
|
+
In the third row seven of ten runs passed and the observed rate meets the threshold, yet the
|
|
159
|
+
gate still fails because the lower bound does not.
|
|
160
|
+
|
|
161
|
+
## Try it without API keys
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
cd examples/faq-bot
|
|
165
|
+
python server.py & # deterministic fake FAQ bot on port 8765
|
|
166
|
+
juried generate # uses the stub provider from juried.toml
|
|
167
|
+
juried run
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
The stub judge passes a response that contains every `"quoted phrase"` in `expected`.
|
|
171
|
+
One hand written refund scenario fails its gate on purpose, because the fake bot drops
|
|
172
|
+
the 14 day detail every fourth time, which is the kind of flakiness juried exists to catch.
|
|
173
|
+
|
|
174
|
+
## Development
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
make check # ruff, mypy and pytest
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
See CONTRIBUTING.md for the development install.
|
|
181
|
+
|
|
182
|
+
Licensed under the MIT licence.
|
juried-0.1.0/README.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/jch1887/juried/main/juried.png" alt="juried" width="360">
|
|
3
|
+
<br>
|
|
4
|
+
Acceptance testing for LLM features, built for QA teams.
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
juried treats your LLM powered feature as a black box behind an HTTP endpoint. You write
|
|
8
|
+
acceptance criteria in plain English, juried generates test scenarios from them, runs each
|
|
9
|
+
scenario repeatedly, has a pinned LLM judge mark every response, and reports pass rates
|
|
10
|
+
with confidence intervals. Scenarios are ordinary pytest tests, so `-k`, `-x`, markers
|
|
11
|
+
and `--junitxml` all work and the results fit an existing CI job. The name says how it
|
|
12
|
+
works: the feature goes before a jury of repeated runs and only passes when the panel's
|
|
13
|
+
verdict holds.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
pip install juried
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Python 3.12 or later. Judges read `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` from the
|
|
22
|
+
environment; nothing else is ever read from disk.
|
|
23
|
+
|
|
24
|
+
## Three commands
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
juried init # writes juried.toml and an example acceptance.md
|
|
28
|
+
juried generate # turns each criterion into scenarios/generated/<criterion>.yaml
|
|
29
|
+
juried run # runs every scenario N times under pytest and writes the report
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`juried run` accepts pytest arguments after its own, for example
|
|
33
|
+
`juried run --runs 20 -k refunds -x --junitxml=out.xml`. Generation is a one off step:
|
|
34
|
+
review and edit the generated YAML, commit it, and `run` never touches it.
|
|
35
|
+
|
|
36
|
+
## Configuration
|
|
37
|
+
|
|
38
|
+
`juried.toml` lives at the repo root. Any key can be overridden with an environment
|
|
39
|
+
variable named `JURIED_<SECTION>_<KEY>`, such as `JURIED_RUN_RUNS=20`. `.env.example`
|
|
40
|
+
lists every variable juried reads, including the provider keys and the overrides;
|
|
41
|
+
copy it to `.env` and export it from your shell, since juried does not load it itself.
|
|
42
|
+
|
|
43
|
+
```toml
|
|
44
|
+
[target]
|
|
45
|
+
url = "https://staging.example.com/api/chat"
|
|
46
|
+
headers = { Authorization = "Bearer ${STAGING_TOKEN}" }
|
|
47
|
+
body = { message = "{{message}}", history = "{{history}}" }
|
|
48
|
+
response_path = "choices.0.message.content"
|
|
49
|
+
|
|
50
|
+
[run]
|
|
51
|
+
runs = 10 # attempts per scenario
|
|
52
|
+
threshold = 0.7 # gate on the lower bound of the Wilson 95% interval
|
|
53
|
+
concurrency = 4
|
|
54
|
+
|
|
55
|
+
[judge]
|
|
56
|
+
provider = "anthropic" # anthropic, openai or stub
|
|
57
|
+
model = "claude-sonnet-5" # pinned and recorded with every verdict
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Set `temperature` under `[judge]` only for a model that accepts it. `claude-sonnet-5` rejects
|
|
61
|
+
the parameter, so the example leaves it out; when it is unset nothing is sent and the report
|
|
62
|
+
says so.
|
|
63
|
+
|
|
64
|
+
`{{message}}` is replaced with the scenario message; a value of exactly `"{{history}}"`
|
|
65
|
+
becomes the earlier turns as a list of `{role, content}` objects. `response_path` is a
|
|
66
|
+
dotted path into the JSON reply.
|
|
67
|
+
|
|
68
|
+
## Criteria and scenarios
|
|
69
|
+
|
|
70
|
+
Criteria are `##` headings in a Markdown file. The heading becomes a stable id; add
|
|
71
|
+
`{#id}` to fix it explicitly.
|
|
72
|
+
|
|
73
|
+
```markdown
|
|
74
|
+
## Refund policy
|
|
75
|
+
The bot explains that any item can be returned within 14 days of delivery for a full
|
|
76
|
+
refund. It must state the 14 day window.
|
|
77
|
+
|
|
78
|
+
## Unknown questions {#unknown}
|
|
79
|
+
When the bot cannot answer it says so and gives help@example.com.
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Generated and hand written scenarios share one YAML shape. Hand written files go
|
|
83
|
+
anywhere under `scenarios/`; `runs` and `threshold` may be set per scenario.
|
|
84
|
+
|
|
85
|
+
```yaml
|
|
86
|
+
criterion: refund-policy
|
|
87
|
+
scenarios:
|
|
88
|
+
- name: Asks how to get money back
|
|
89
|
+
kind: happy_path
|
|
90
|
+
message: I want my money back on a jumper that does not fit.
|
|
91
|
+
expected: States the 14 day return window and that the refund is full.
|
|
92
|
+
- name: Follows up after a delivery answer
|
|
93
|
+
kind: edge_case
|
|
94
|
+
history:
|
|
95
|
+
- role: user
|
|
96
|
+
content: Can you tell me about delivery?
|
|
97
|
+
- role: assistant
|
|
98
|
+
content: Standard delivery takes 3 to 5 working days.
|
|
99
|
+
message: and refunds?
|
|
100
|
+
expected: Explains the 14 day refund window without repeating the delivery answer.
|
|
101
|
+
runs: 20
|
|
102
|
+
threshold: 0.8
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
A phrase in double quotes inside `expected` must appear in the response word for word,
|
|
106
|
+
ignoring case. Text outside quotes is judged on meaning. With
|
|
107
|
+
`expected: Says returns are accepted within "14 days" for a full refund`, a response saying
|
|
108
|
+
"you have 14 days and get every penny back" passes, while "a fortnight for a full refund"
|
|
109
|
+
fails because `14 days` is missing. The stub judge applies the same rule.
|
|
110
|
+
|
|
111
|
+
## How a scenario passes
|
|
112
|
+
|
|
113
|
+
Each scenario runs `runs` times. The judge marks each response pass or fail with a one
|
|
114
|
+
line reason, using a fixed prompt and the configured temperature, if any. juried computes the
|
|
115
|
+
pass rate and its
|
|
116
|
+
Wilson 95% interval, and the scenario passes when the lower bound meets `threshold`. With
|
|
117
|
+
10 runs a perfect score gives a lower bound of 0.72, so the default threshold is 0.7; a
|
|
118
|
+
stricter threshold needs more runs, and juried warns when a gate can never pass.
|
|
119
|
+
|
|
120
|
+
A failing gate is a normal pytest failure that shows the pass rate, the interval, the
|
|
121
|
+
threshold and the first failing transcript with the judge's reason. An HTTP error from
|
|
122
|
+
your endpoint is reported as a transport error, separately from a judge fail. Responses
|
|
123
|
+
and verdicts are cached in `.juried/` by content hash, so re-runs during development are
|
|
124
|
+
cheap; pass `--no-cache` to skip it. Every verdict is appended to `.juried/verdicts.jsonl`
|
|
125
|
+
with the judge model and timestamp.
|
|
126
|
+
|
|
127
|
+
## The report
|
|
128
|
+
|
|
129
|
+
After a run juried writes `reports/juried-report.html` and `reports/juried-report.json`.
|
|
130
|
+
The HTML is a single self contained file with no scripts. It opens with the totals, then
|
|
131
|
+
lists each acceptance criterion with its description, a table of its scenarios showing
|
|
132
|
+
passes, pass rate, interval, threshold, response latency and gate result, and beneath the
|
|
133
|
+
table each scenario's message, expectation and every failing run with the response and
|
|
134
|
+
the judge's reason. Criteria with no scenarios are called out so coverage gaps are
|
|
135
|
+
visible. The JSON file holds the same structure plus every attempt, for anyone who wants
|
|
136
|
+
to chart trends.
|
|
137
|
+
|
|
138
|
+
<img src="https://raw.githubusercontent.com/jch1887/juried/main/docs/report.png" alt="juried report showing three scenarios, one at 7 of 10 failing because its lower bound is below the threshold" width="900">
|
|
139
|
+
|
|
140
|
+
In the third row seven of ten runs passed and the observed rate meets the threshold, yet the
|
|
141
|
+
gate still fails because the lower bound does not.
|
|
142
|
+
|
|
143
|
+
## Try it without API keys
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
cd examples/faq-bot
|
|
147
|
+
python server.py & # deterministic fake FAQ bot on port 8765
|
|
148
|
+
juried generate # uses the stub provider from juried.toml
|
|
149
|
+
juried run
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The stub judge passes a response that contains every `"quoted phrase"` in `expected`.
|
|
153
|
+
One hand written refund scenario fails its gate on purpose, because the fake bot drops
|
|
154
|
+
the 14 day detail every fourth time, which is the kind of flakiness juried exists to catch.
|
|
155
|
+
|
|
156
|
+
## Development
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
make check # ruff, mypy and pytest
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
See CONTRIBUTING.md for the development install.
|
|
163
|
+
|
|
164
|
+
Licensed under the MIT licence.
|
|
Binary file
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# FAQ bot acceptance criteria
|
|
2
|
+
|
|
3
|
+
## Opening hours
|
|
4
|
+
The bot tells customers the opening hours: Monday to Friday, 9am to 5pm. It says the
|
|
5
|
+
shop is closed at weekends.
|
|
6
|
+
|
|
7
|
+
## Refund policy
|
|
8
|
+
The bot explains that any item can be returned within 14 days of delivery for a full
|
|
9
|
+
refund. It must state the 14 day window.
|
|
10
|
+
|
|
11
|
+
## Delivery times
|
|
12
|
+
The bot states that standard delivery takes 3 to 5 working days.
|
|
13
|
+
|
|
14
|
+
## Unknown questions {#unknown}
|
|
15
|
+
When the bot cannot answer it says so and gives the support address help@example.com
|
|
16
|
+
rather than inventing an answer.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[target]
|
|
2
|
+
url = "http://127.0.0.1:8765/chat"
|
|
3
|
+
body = { message = "{{message}}", history = "{{history}}" }
|
|
4
|
+
response_path = "reply"
|
|
5
|
+
|
|
6
|
+
[run]
|
|
7
|
+
runs = 10
|
|
8
|
+
threshold = 0.7
|
|
9
|
+
|
|
10
|
+
[judge]
|
|
11
|
+
provider = "stub"
|
|
12
|
+
|
|
13
|
+
[generate]
|
|
14
|
+
scenarios_per_criterion = 2
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Hand written scenarios. The stub judge passes a response that contains every
|
|
2
|
+
# "quoted phrase" in expected; a real judge reads the whole expectation.
|
|
3
|
+
scenarios:
|
|
4
|
+
- criterion: opening-hours
|
|
5
|
+
name: Asks when the shop is open
|
|
6
|
+
kind: happy_path
|
|
7
|
+
message: What time are you open?
|
|
8
|
+
expected: Gives the hours, including "9am" and "5pm", and mentions "weekends".
|
|
9
|
+
|
|
10
|
+
- criterion: opening-hours
|
|
11
|
+
name: Asks about Sunday
|
|
12
|
+
kind: edge_case
|
|
13
|
+
message: can i pop in on sunday?
|
|
14
|
+
expected: Says the shop is closed at "weekends".
|
|
15
|
+
|
|
16
|
+
- criterion: refund-policy
|
|
17
|
+
name: Asks how to get money back
|
|
18
|
+
kind: happy_path
|
|
19
|
+
message: I want my money back on a jumper that does not fit.
|
|
20
|
+
expected: States the "14 days" return window and that the refund is "full".
|
|
21
|
+
|
|
22
|
+
- criterion: unknown
|
|
23
|
+
name: Asks something off topic
|
|
24
|
+
kind: edge_case
|
|
25
|
+
message: Do you know a good pizza place nearby?
|
|
26
|
+
expected: Admits it does not know and gives "help@example.com".
|
|
27
|
+
|
|
28
|
+
- criterion: unknown
|
|
29
|
+
name: Follows up with yes
|
|
30
|
+
kind: edge_case
|
|
31
|
+
history:
|
|
32
|
+
- role: user
|
|
33
|
+
content: Can you tell me about delivery?
|
|
34
|
+
- role: assistant
|
|
35
|
+
content: Standard delivery takes 3 to 5 working days and costs 3.99.
|
|
36
|
+
message: yes please
|
|
37
|
+
expected: Stays on the delivery topic, mentioning "working days".
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""A deterministic stand in for an LLM backed FAQ bot, so the example runs without API keys.
|
|
2
|
+
|
|
3
|
+
Run it with `python server.py` and point juried at http://127.0.0.1:8765/chat. Every fourth
|
|
4
|
+
refund question deliberately drops the 14 day detail, so that one scenario shows a flaky
|
|
5
|
+
pass rate in the report.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import threading
|
|
12
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
PORT = 8765
|
|
16
|
+
|
|
17
|
+
HOURS = "We are open Monday to Friday, 9am to 5pm, and closed at weekends."
|
|
18
|
+
REFUND_FULL = "You can return any item within 14 days of delivery for a full refund."
|
|
19
|
+
REFUND_VAGUE = "Returns are accepted for a full refund, please contact us to arrange one."
|
|
20
|
+
DELIVERY = "Standard delivery takes 3 to 5 working days and costs 3.99."
|
|
21
|
+
UNKNOWN = "I'm not sure about that. Please email help@example.com and the team will help."
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Handler(BaseHTTPRequestHandler):
|
|
25
|
+
refund_calls = 0
|
|
26
|
+
lock = threading.Lock()
|
|
27
|
+
|
|
28
|
+
def do_POST(self) -> None:
|
|
29
|
+
if self.path != "/chat":
|
|
30
|
+
self.reply(404, {"error": "not found"})
|
|
31
|
+
return
|
|
32
|
+
length = int(self.headers.get("Content-Length", "0"))
|
|
33
|
+
try:
|
|
34
|
+
payload = json.loads(self.rfile.read(length) or b"{}")
|
|
35
|
+
except json.JSONDecodeError:
|
|
36
|
+
self.reply(400, {"error": "body must be JSON"})
|
|
37
|
+
return
|
|
38
|
+
message = str(payload.get("message", "")).lower()
|
|
39
|
+
history = payload.get("history") or []
|
|
40
|
+
self.reply(200, {"reply": answer(message, history)})
|
|
41
|
+
|
|
42
|
+
def reply(self, status: int, body: dict[str, Any]) -> None:
|
|
43
|
+
data = json.dumps(body).encode("utf-8")
|
|
44
|
+
self.send_response(status)
|
|
45
|
+
self.send_header("Content-Type", "application/json")
|
|
46
|
+
self.send_header("Content-Length", str(len(data)))
|
|
47
|
+
self.end_headers()
|
|
48
|
+
self.wfile.write(data)
|
|
49
|
+
|
|
50
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def answer(message: str, history: list[dict[str, str]]) -> str:
|
|
55
|
+
if any(word in message for word in ("hour", "open", "close", "weekend", "saturday", "sunday")):
|
|
56
|
+
return HOURS
|
|
57
|
+
if any(word in message for word in ("refund", "return", "money back")):
|
|
58
|
+
with Handler.lock:
|
|
59
|
+
Handler.refund_calls += 1
|
|
60
|
+
vague = Handler.refund_calls % 4 == 0
|
|
61
|
+
return REFUND_VAGUE if vague else REFUND_FULL
|
|
62
|
+
if any(word in message for word in ("deliver", "shipping", "post")):
|
|
63
|
+
return DELIVERY
|
|
64
|
+
if history and message.strip() in {"yes", "yes please", "ok", "go on"}:
|
|
65
|
+
return "Of course. " + answer(history[-1].get("content", "").lower(), [])
|
|
66
|
+
return UNKNOWN
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main() -> None:
|
|
70
|
+
server = ThreadingHTTPServer(("127.0.0.1", PORT), Handler)
|
|
71
|
+
print(f"faq-bot listening on http://127.0.0.1:{PORT}/chat")
|
|
72
|
+
try:
|
|
73
|
+
server.serve_forever()
|
|
74
|
+
except KeyboardInterrupt:
|
|
75
|
+
server.shutdown()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
main()
|
juried-0.1.0/juried.png
ADDED
|
Binary file
|