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.
Files changed (51) hide show
  1. juried-0.1.0/.env.example +61 -0
  2. juried-0.1.0/.gitignore +13 -0
  3. juried-0.1.0/CONTRIBUTING.md +37 -0
  4. juried-0.1.0/LICENCE +21 -0
  5. juried-0.1.0/Makefile +21 -0
  6. juried-0.1.0/PKG-INFO +182 -0
  7. juried-0.1.0/README.md +164 -0
  8. juried-0.1.0/docs/report.png +0 -0
  9. juried-0.1.0/examples/faq-bot/acceptance.md +16 -0
  10. juried-0.1.0/examples/faq-bot/juried.toml +14 -0
  11. juried-0.1.0/examples/faq-bot/scenarios/handwritten.yaml +37 -0
  12. juried-0.1.0/examples/faq-bot/server.py +79 -0
  13. juried-0.1.0/juried.png +0 -0
  14. juried-0.1.0/pyproject.toml +52 -0
  15. juried-0.1.0/src/juried/__init__.py +1 -0
  16. juried-0.1.0/src/juried/cache.py +48 -0
  17. juried-0.1.0/src/juried/cli.py +208 -0
  18. juried-0.1.0/src/juried/config.py +154 -0
  19. juried-0.1.0/src/juried/criteria.py +62 -0
  20. juried-0.1.0/src/juried/generate.py +115 -0
  21. juried-0.1.0/src/juried/judge/__init__.py +33 -0
  22. juried-0.1.0/src/juried/judge/anthropic.py +50 -0
  23. juried-0.1.0/src/juried/judge/base.py +196 -0
  24. juried-0.1.0/src/juried/judge/openai.py +44 -0
  25. juried-0.1.0/src/juried/judge/prompts.py +102 -0
  26. juried-0.1.0/src/juried/judge/stub.py +52 -0
  27. juried-0.1.0/src/juried/pytest_plugin.py +279 -0
  28. juried-0.1.0/src/juried/report/__init__.py +37 -0
  29. juried-0.1.0/src/juried/report/html.py +26 -0
  30. juried-0.1.0/src/juried/report/json.py +83 -0
  31. juried-0.1.0/src/juried/report/logo.png +0 -0
  32. juried-0.1.0/src/juried/report/template.html +126 -0
  33. juried-0.1.0/src/juried/runner.py +253 -0
  34. juried-0.1.0/src/juried/scenarios.py +120 -0
  35. juried-0.1.0/src/juried/stats.py +33 -0
  36. juried-0.1.0/src/juried/targets/__init__.py +4 -0
  37. juried-0.1.0/src/juried/targets/base.py +21 -0
  38. juried-0.1.0/src/juried/targets/http.py +111 -0
  39. juried-0.1.0/src/juried/transport.py +48 -0
  40. juried-0.1.0/tests/conftest.py +52 -0
  41. juried-0.1.0/tests/test_cli.py +116 -0
  42. juried-0.1.0/tests/test_config.py +106 -0
  43. juried-0.1.0/tests/test_criteria.py +48 -0
  44. juried-0.1.0/tests/test_generate.py +59 -0
  45. juried-0.1.0/tests/test_judge.py +264 -0
  46. juried-0.1.0/tests/test_plugin.py +205 -0
  47. juried-0.1.0/tests/test_report.py +163 -0
  48. juried-0.1.0/tests/test_runner.py +221 -0
  49. juried-0.1.0/tests/test_scenarios.py +122 -0
  50. juried-0.1.0/tests/test_stats.py +51 -0
  51. 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
@@ -0,0 +1,13 @@
1
+ .juried/
2
+ .venv/
3
+ reports/
4
+ __pycache__/
5
+ *.pyc
6
+ *.egg-info/
7
+ dist/
8
+ .mypy_cache/
9
+ .ruff_cache/
10
+ .pytest_cache/
11
+ examples/faq-bot/scenarios/generated/
12
+ out.xml
13
+ .env
@@ -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()
Binary file