qe-harness 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.
- qe_harness-0.1.0/LICENSE +21 -0
- qe_harness-0.1.0/PKG-INFO +143 -0
- qe_harness-0.1.0/README.md +110 -0
- qe_harness-0.1.0/pyproject.toml +52 -0
- qe_harness-0.1.0/qe_harness/__init__.py +12 -0
- qe_harness-0.1.0/qe_harness/__main__.py +3 -0
- qe_harness-0.1.0/qe_harness/capture.py +101 -0
- qe_harness-0.1.0/qe_harness/catalog.py +49 -0
- qe_harness-0.1.0/qe_harness/cli.py +273 -0
- qe_harness-0.1.0/qe_harness/config.py +113 -0
- qe_harness-0.1.0/qe_harness/coverage.py +110 -0
- qe_harness-0.1.0/qe_harness/doctor.py +92 -0
- qe_harness-0.1.0/qe_harness/driver.py +92 -0
- qe_harness-0.1.0/qe_harness/evaluate.py +207 -0
- qe_harness-0.1.0/qe_harness/judge.py +70 -0
- qe_harness-0.1.0/qe_harness/mcp_proxy.py +85 -0
- qe_harness-0.1.0/qe_harness/mcp_server.py +196 -0
- qe_harness-0.1.0/qe_harness/report.py +307 -0
- qe_harness-0.1.0/qe_harness/runner.py +182 -0
- qe_harness-0.1.0/qe_harness/sandbox.py +87 -0
- qe_harness-0.1.0/qe_harness/templates/qe.yaml +41 -0
- qe_harness-0.1.0/qe_harness/templates/qe_catalog.yaml +39 -0
- qe_harness-0.1.0/qe_harness.egg-info/PKG-INFO +143 -0
- qe_harness-0.1.0/qe_harness.egg-info/SOURCES.txt +27 -0
- qe_harness-0.1.0/qe_harness.egg-info/dependency_links.txt +1 -0
- qe_harness-0.1.0/qe_harness.egg-info/entry_points.txt +2 -0
- qe_harness-0.1.0/qe_harness.egg-info/requires.txt +15 -0
- qe_harness-0.1.0/qe_harness.egg-info/top_level.txt +1 -0
- qe_harness-0.1.0/setup.cfg +4 -0
qe_harness-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shan Konduru
|
|
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.
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: qe-harness
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Black-box, five-stage validation of conversation-to-action AI assistants: intent, orchestration, MCP tool calls, backend state and user response
|
|
5
|
+
Author: Shan Konduru
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: testing,qa,llm,agents,mcp,evaluation,chatbot
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
16
|
+
Classifier: Topic :: Software Development :: Testing
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: mcp>=2.3
|
|
21
|
+
Requires-Dist: pydantic>=2.7
|
|
22
|
+
Requires-Dist: pyyaml>=6
|
|
23
|
+
Requires-Dist: python-dotenv>=1.0
|
|
24
|
+
Requires-Dist: websockets>=12
|
|
25
|
+
Provides-Extra: azure
|
|
26
|
+
Requires-Dist: openai>=1.50; extra == "azure"
|
|
27
|
+
Provides-Extra: anthropic
|
|
28
|
+
Requires-Dist: anthropic>=2.4; extra == "anthropic"
|
|
29
|
+
Provides-Extra: judge
|
|
30
|
+
Requires-Dist: openai>=1.50; extra == "judge"
|
|
31
|
+
Requires-Dist: anthropic>=2.4; extra == "judge"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# qe-harness
|
|
35
|
+
|
|
36
|
+
Black-box, five-stage validation of conversation-to-action AI assistants.
|
|
37
|
+
|
|
38
|
+
A chat-only test sees the final reply. `qe-harness` tests every turn at five stages, from the outside:
|
|
39
|
+
|
|
40
|
+
**intent → orchestration → MCP tool calls → backend state → user response**
|
|
41
|
+
|
|
42
|
+
It never imports the application under test. It signs in as test users and chats over the app's API, reads the
|
|
43
|
+
app's per-turn traces, records every tool call independently through its own MCP proxy, and compares read-only
|
|
44
|
+
snapshots of the business data before and after each turn. So it catches what a polite reply hides: a write the
|
|
45
|
+
customer never approved, an amount no tool returned, a reply that says "done" when nothing was saved.
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install qe-harness # core
|
|
51
|
+
pip install "qe-harness[judge]" # plus the optional LLM grader (Azure OpenAI or Anthropic)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Python 3.11 or newer.
|
|
55
|
+
|
|
56
|
+
## Quick start
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
qe init # writes qe.yaml (sandbox connection) and qe_catalog.yaml (test cases) in this folder
|
|
60
|
+
# edit both files, then provide the two secrets the config names:
|
|
61
|
+
# QE_SANDBOX_TOKEN sandbox control token
|
|
62
|
+
# QE_TEST_PASSWORD test users' password
|
|
63
|
+
# (environment variables, or a .env file next to qe.yaml)
|
|
64
|
+
qe doctor # connection checks: all green means testing can start
|
|
65
|
+
qe run --open # run every suite, evaluate each turn, open the HTML report
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`qe` finds its configuration from `--config PATH`, then `$QE_CONFIG`, then `qe.yaml` in the working directory.
|
|
69
|
+
Relative paths in it start from the folder holding the file (or from `base_dir:` if set).
|
|
70
|
+
|
|
71
|
+
## What the app's sandbox must provide
|
|
72
|
+
|
|
73
|
+
| Need | Endpoint or setting |
|
|
74
|
+
|---|---|
|
|
75
|
+
| Sign-in as test users | `POST /api/login` `{"username", "password"}`, session cookie |
|
|
76
|
+
| Conversations | `POST /api/conversations` → `{"id"}`; WebSocket `/ws?conversation=<id>` with messages `{"type": "user", "text"}` and `{"type": "confirm", "key", "approved"}`; events `ready`, `awaiting_confirmation`, `assistant_message`, `error`, `turn_completed` |
|
|
77
|
+
| Per-turn traces | `GET /api/conversations/<id>/traces`: intent (`primary_intent`, `intents`), plan steps, tool calls with arguments and outcomes |
|
|
78
|
+
| File uploads (optional) | `POST /api/conversations/<id>/documents?filename=…` |
|
|
79
|
+
| Control | `GET /sandbox/status` (versions, clock, toggles) and `POST /sandbox/reset`, with header `x-sandbox-token` |
|
|
80
|
+
| Independent tool record | Start the MCP tool server through `python -m qe_harness.mcp_proxy --log-dir <dir> -- <server command>` |
|
|
81
|
+
| Business data | A read-only SQLite replica the harness can snapshot |
|
|
82
|
+
|
|
83
|
+
## Commands
|
|
84
|
+
|
|
85
|
+
| Command | What it does |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `qe init [--dir D] [--force]` | Starter `qe.yaml` and `qe_catalog.yaml` |
|
|
88
|
+
| `qe doctor [--quick]` | Network, control API, clock, versions, proxy toggle, reset, read-back, sign-in, round trip, trace link, tool capture |
|
|
89
|
+
| `qe status` | Sandbox versions, clock and toggles |
|
|
90
|
+
| `qe cases [--suite S]` | List catalog cases |
|
|
91
|
+
| `qe run [--suite S…] [--case ID] [--repeats N] [--judge] [--open]` | Reset the sandbox, run the suites, evaluate every turn, write evidence and the HTML report |
|
|
92
|
+
| `qe runs` | Recent runs |
|
|
93
|
+
| `qe show RUN CASE` | Evidence for one case: message, approvals, intent, plan, trace and proxy tool calls, data changes, reply, verdicts |
|
|
94
|
+
| `qe report RUN [--open]` | Rebuild the self-contained HTML report |
|
|
95
|
+
| `qe coverage [RUN] [--trend]` | Intent coverage for a run, or per intent across runs |
|
|
96
|
+
| `qe mcp serve` | Run the harness as an MCP server for AI assistants |
|
|
97
|
+
|
|
98
|
+
## What is checked per turn
|
|
99
|
+
|
|
100
|
+
| Stage | Checks |
|
|
101
|
+
|---|---|
|
|
102
|
+
| Intent | The router's primary intent is one of the expected intents |
|
|
103
|
+
| Orchestration | Writes only after approval; no unexpected approval prompts; no repeated identical calls |
|
|
104
|
+
| MCP tool call | Required calls succeeded with the expected arguments; forbidden calls absent; **trace and proxy agree** |
|
|
105
|
+
| Backend state | Only the expected tables changed; SQL checks; **declined → nothing changed; data changed → a write succeeded** |
|
|
106
|
+
| User response | Required facts present, forbidden phrases absent, optional LLM grader for consistency with what the tools did |
|
|
107
|
+
|
|
108
|
+
Each failure records its first failing stage, so developers know where to look. With `--repeats N`, every case gets
|
|
109
|
+
a pass rate with a 95% Wilson range and unstable cases are reported as flaky.
|
|
110
|
+
|
|
111
|
+
## Intent coverage
|
|
112
|
+
|
|
113
|
+
List the app's intent families under `intents:` in `qe.yaml`. Each report shows, per family, the cases that test it,
|
|
114
|
+
their pass rate and a status (covered, thin, failing, not tested), plus a families × runs grid of how coverage
|
|
115
|
+
changes over time. `qe coverage --trend` shows the same in the terminal.
|
|
116
|
+
|
|
117
|
+
## In AI assistants (MCP)
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
claude mcp add qe-harness -- qe mcp serve
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
For Cursor or VS Code, add a stdio server with command `qe` and args `["mcp", "serve"]`.
|
|
124
|
+
|
|
125
|
+
Tools: `sandbox_status`, `sandbox_doctor`, `list_cases`, `get_case`, `run_tests` (changes state; capped per call),
|
|
126
|
+
`get_run_status`, `list_runs`, `get_failure` (masked evidence, marked as untrusted data), `get_intent_coverage`,
|
|
127
|
+
`get_intent_trend`. Prompt: `triage_run`.
|
|
128
|
+
|
|
129
|
+
## LLM grader (optional)
|
|
130
|
+
|
|
131
|
+
`qe run --judge` asks a model whether each reply is consistent with what the tools actually did. It uses its own
|
|
132
|
+
settings, separate from the app's: `QE_JUDGE_PROVIDER` = `AZURE` (with `QE_JUDGE_AZURE_API_KEY`,
|
|
133
|
+
`QE_JUDGE_AZURE_ENDPOINT`, `QE_JUDGE_AZURE_DEPLOYMENT`) or `ANTHROPIC` (with `ANTHROPIC_API_KEY`, optional
|
|
134
|
+
`QE_JUDGE_MODEL`).
|
|
135
|
+
|
|
136
|
+
## Status
|
|
137
|
+
|
|
138
|
+
Alpha. Not yet included: fault injection, load and soak testing, simulated customers, robustness variant
|
|
139
|
+
generation and a release gate.
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
MIT
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# qe-harness
|
|
2
|
+
|
|
3
|
+
Black-box, five-stage validation of conversation-to-action AI assistants.
|
|
4
|
+
|
|
5
|
+
A chat-only test sees the final reply. `qe-harness` tests every turn at five stages, from the outside:
|
|
6
|
+
|
|
7
|
+
**intent → orchestration → MCP tool calls → backend state → user response**
|
|
8
|
+
|
|
9
|
+
It never imports the application under test. It signs in as test users and chats over the app's API, reads the
|
|
10
|
+
app's per-turn traces, records every tool call independently through its own MCP proxy, and compares read-only
|
|
11
|
+
snapshots of the business data before and after each turn. So it catches what a polite reply hides: a write the
|
|
12
|
+
customer never approved, an amount no tool returned, a reply that says "done" when nothing was saved.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install qe-harness # core
|
|
18
|
+
pip install "qe-harness[judge]" # plus the optional LLM grader (Azure OpenAI or Anthropic)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Python 3.11 or newer.
|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
qe init # writes qe.yaml (sandbox connection) and qe_catalog.yaml (test cases) in this folder
|
|
27
|
+
# edit both files, then provide the two secrets the config names:
|
|
28
|
+
# QE_SANDBOX_TOKEN sandbox control token
|
|
29
|
+
# QE_TEST_PASSWORD test users' password
|
|
30
|
+
# (environment variables, or a .env file next to qe.yaml)
|
|
31
|
+
qe doctor # connection checks: all green means testing can start
|
|
32
|
+
qe run --open # run every suite, evaluate each turn, open the HTML report
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`qe` finds its configuration from `--config PATH`, then `$QE_CONFIG`, then `qe.yaml` in the working directory.
|
|
36
|
+
Relative paths in it start from the folder holding the file (or from `base_dir:` if set).
|
|
37
|
+
|
|
38
|
+
## What the app's sandbox must provide
|
|
39
|
+
|
|
40
|
+
| Need | Endpoint or setting |
|
|
41
|
+
|---|---|
|
|
42
|
+
| Sign-in as test users | `POST /api/login` `{"username", "password"}`, session cookie |
|
|
43
|
+
| Conversations | `POST /api/conversations` → `{"id"}`; WebSocket `/ws?conversation=<id>` with messages `{"type": "user", "text"}` and `{"type": "confirm", "key", "approved"}`; events `ready`, `awaiting_confirmation`, `assistant_message`, `error`, `turn_completed` |
|
|
44
|
+
| Per-turn traces | `GET /api/conversations/<id>/traces`: intent (`primary_intent`, `intents`), plan steps, tool calls with arguments and outcomes |
|
|
45
|
+
| File uploads (optional) | `POST /api/conversations/<id>/documents?filename=…` |
|
|
46
|
+
| Control | `GET /sandbox/status` (versions, clock, toggles) and `POST /sandbox/reset`, with header `x-sandbox-token` |
|
|
47
|
+
| Independent tool record | Start the MCP tool server through `python -m qe_harness.mcp_proxy --log-dir <dir> -- <server command>` |
|
|
48
|
+
| Business data | A read-only SQLite replica the harness can snapshot |
|
|
49
|
+
|
|
50
|
+
## Commands
|
|
51
|
+
|
|
52
|
+
| Command | What it does |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `qe init [--dir D] [--force]` | Starter `qe.yaml` and `qe_catalog.yaml` |
|
|
55
|
+
| `qe doctor [--quick]` | Network, control API, clock, versions, proxy toggle, reset, read-back, sign-in, round trip, trace link, tool capture |
|
|
56
|
+
| `qe status` | Sandbox versions, clock and toggles |
|
|
57
|
+
| `qe cases [--suite S]` | List catalog cases |
|
|
58
|
+
| `qe run [--suite S…] [--case ID] [--repeats N] [--judge] [--open]` | Reset the sandbox, run the suites, evaluate every turn, write evidence and the HTML report |
|
|
59
|
+
| `qe runs` | Recent runs |
|
|
60
|
+
| `qe show RUN CASE` | Evidence for one case: message, approvals, intent, plan, trace and proxy tool calls, data changes, reply, verdicts |
|
|
61
|
+
| `qe report RUN [--open]` | Rebuild the self-contained HTML report |
|
|
62
|
+
| `qe coverage [RUN] [--trend]` | Intent coverage for a run, or per intent across runs |
|
|
63
|
+
| `qe mcp serve` | Run the harness as an MCP server for AI assistants |
|
|
64
|
+
|
|
65
|
+
## What is checked per turn
|
|
66
|
+
|
|
67
|
+
| Stage | Checks |
|
|
68
|
+
|---|---|
|
|
69
|
+
| Intent | The router's primary intent is one of the expected intents |
|
|
70
|
+
| Orchestration | Writes only after approval; no unexpected approval prompts; no repeated identical calls |
|
|
71
|
+
| MCP tool call | Required calls succeeded with the expected arguments; forbidden calls absent; **trace and proxy agree** |
|
|
72
|
+
| Backend state | Only the expected tables changed; SQL checks; **declined → nothing changed; data changed → a write succeeded** |
|
|
73
|
+
| User response | Required facts present, forbidden phrases absent, optional LLM grader for consistency with what the tools did |
|
|
74
|
+
|
|
75
|
+
Each failure records its first failing stage, so developers know where to look. With `--repeats N`, every case gets
|
|
76
|
+
a pass rate with a 95% Wilson range and unstable cases are reported as flaky.
|
|
77
|
+
|
|
78
|
+
## Intent coverage
|
|
79
|
+
|
|
80
|
+
List the app's intent families under `intents:` in `qe.yaml`. Each report shows, per family, the cases that test it,
|
|
81
|
+
their pass rate and a status (covered, thin, failing, not tested), plus a families × runs grid of how coverage
|
|
82
|
+
changes over time. `qe coverage --trend` shows the same in the terminal.
|
|
83
|
+
|
|
84
|
+
## In AI assistants (MCP)
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
claude mcp add qe-harness -- qe mcp serve
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
For Cursor or VS Code, add a stdio server with command `qe` and args `["mcp", "serve"]`.
|
|
91
|
+
|
|
92
|
+
Tools: `sandbox_status`, `sandbox_doctor`, `list_cases`, `get_case`, `run_tests` (changes state; capped per call),
|
|
93
|
+
`get_run_status`, `list_runs`, `get_failure` (masked evidence, marked as untrusted data), `get_intent_coverage`,
|
|
94
|
+
`get_intent_trend`. Prompt: `triage_run`.
|
|
95
|
+
|
|
96
|
+
## LLM grader (optional)
|
|
97
|
+
|
|
98
|
+
`qe run --judge` asks a model whether each reply is consistent with what the tools actually did. It uses its own
|
|
99
|
+
settings, separate from the app's: `QE_JUDGE_PROVIDER` = `AZURE` (with `QE_JUDGE_AZURE_API_KEY`,
|
|
100
|
+
`QE_JUDGE_AZURE_ENDPOINT`, `QE_JUDGE_AZURE_DEPLOYMENT`) or `ANTHROPIC` (with `ANTHROPIC_API_KEY`, optional
|
|
101
|
+
`QE_JUDGE_MODEL`).
|
|
102
|
+
|
|
103
|
+
## Status
|
|
104
|
+
|
|
105
|
+
Alpha. Not yet included: fault injection, load and soak testing, simulated customers, robustness variant
|
|
106
|
+
generation and a release gate.
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Package definition for publishing the QE harness on its own as `qe-harness`.
|
|
2
|
+
# Build with: python tools/build_harness.py (stages qe_harness/ next to this file, then builds sdist + wheel)
|
|
3
|
+
[build-system]
|
|
4
|
+
requires = ["setuptools>=77"]
|
|
5
|
+
build-backend = "setuptools.build_meta"
|
|
6
|
+
|
|
7
|
+
[project]
|
|
8
|
+
name = "qe-harness"
|
|
9
|
+
dynamic = ["version"]
|
|
10
|
+
description = "Black-box, five-stage validation of conversation-to-action AI assistants: intent, orchestration, MCP tool calls, backend state and user response"
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
requires-python = ">=3.11"
|
|
13
|
+
license = "MIT"
|
|
14
|
+
license-files = ["LICENSE"]
|
|
15
|
+
authors = [{ name = "Shan Konduru" }]
|
|
16
|
+
keywords = ["testing", "qa", "llm", "agents", "mcp", "evaluation", "chatbot"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
26
|
+
"Topic :: Software Development :: Testing",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"mcp>=2.3",
|
|
30
|
+
"pydantic>=2.7",
|
|
31
|
+
"pyyaml>=6",
|
|
32
|
+
"python-dotenv>=1.0",
|
|
33
|
+
"websockets>=12",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.optional-dependencies]
|
|
37
|
+
# The optional LLM grader (qe run --judge) calls Azure OpenAI or Anthropic.
|
|
38
|
+
azure = ["openai>=1.50"]
|
|
39
|
+
anthropic = ["anthropic>=2.4"]
|
|
40
|
+
judge = ["openai>=1.50", "anthropic>=2.4"]
|
|
41
|
+
|
|
42
|
+
[project.scripts]
|
|
43
|
+
qe = "qe_harness.cli:main"
|
|
44
|
+
|
|
45
|
+
[tool.setuptools]
|
|
46
|
+
packages = ["qe_harness"]
|
|
47
|
+
|
|
48
|
+
[tool.setuptools.package-data]
|
|
49
|
+
qe_harness = ["templates/*.yaml"]
|
|
50
|
+
|
|
51
|
+
[tool.setuptools.dynamic]
|
|
52
|
+
version = { attr = "qe_harness.__version__" }
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""QE validation harness (prototype).
|
|
2
|
+
|
|
3
|
+
Tests a conversation-to-action assistant from the outside, at five stages:
|
|
4
|
+
intent, orchestration, MCP tool calls, backend state and user response.
|
|
5
|
+
|
|
6
|
+
The harness never imports the application: it signs in and chats over the app's
|
|
7
|
+
public API, reads traces through the sandbox's trace API, captures tool calls
|
|
8
|
+
through its own recording proxy, and reads business data through a read-only
|
|
9
|
+
connection - exactly what a QE partner gets from a client sandbox.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Evidence capture outside the conversation: proxy tool-call log and read-only state snapshots."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import sqlite3
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from qe_harness.config import HarnessConfig
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ------------------------------------------------------------------ proxy log
|
|
15
|
+
def proxy_calls(config: HarnessConfig, customer_id: str, start: float, end: float) -> list[dict[str, Any]]:
|
|
16
|
+
"""Tool calls the recording proxy saw for one customer in a time window, with their outcome."""
|
|
17
|
+
requests: dict[Any, dict] = {}
|
|
18
|
+
responses: dict[Any, dict] = {}
|
|
19
|
+
if not config.proxy_log_dir.exists():
|
|
20
|
+
return []
|
|
21
|
+
for path in config.proxy_log_dir.glob(f"{customer_id}-*.jsonl"):
|
|
22
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
23
|
+
try:
|
|
24
|
+
entry = json.loads(line)
|
|
25
|
+
except ValueError:
|
|
26
|
+
continue
|
|
27
|
+
if not (start - 0.5 <= entry["ts"] <= end + 0.5):
|
|
28
|
+
continue
|
|
29
|
+
msg = entry["message"]
|
|
30
|
+
key = (path.name, msg.get("id"))
|
|
31
|
+
if entry["direction"] == "request":
|
|
32
|
+
requests[key] = {"ts": entry["ts"], **msg}
|
|
33
|
+
else:
|
|
34
|
+
responses[key] = msg
|
|
35
|
+
calls = []
|
|
36
|
+
for key, req in sorted(requests.items(), key=lambda kv: kv[1]["ts"]):
|
|
37
|
+
params = req.get("params", {})
|
|
38
|
+
resp = responses.get(key, {})
|
|
39
|
+
result = resp.get("result", {}) or {}
|
|
40
|
+
text = "\n".join(c.get("text", "") for c in result.get("content", []) if isinstance(c, dict))
|
|
41
|
+
calls.append({"name": params.get("name"), "arguments": params.get("arguments") or {},
|
|
42
|
+
"is_error": bool(result.get("isError") or result.get("is_error") or "error" in resp),
|
|
43
|
+
"result": text[:20000], "ts": req["ts"]})
|
|
44
|
+
return calls
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ------------------------------------------------------------------ snapshots
|
|
48
|
+
def snapshot(config: HarnessConfig) -> dict[str, dict[str, dict[str, Any]]]:
|
|
49
|
+
"""Business data keyed by table and primary key, through a read-only connection."""
|
|
50
|
+
uri = f"file:{config.sor_path.as_posix()}?mode=ro"
|
|
51
|
+
con = sqlite3.connect(uri, uri=True)
|
|
52
|
+
con.row_factory = sqlite3.Row
|
|
53
|
+
out: dict[str, dict[str, dict[str, Any]]] = {}
|
|
54
|
+
try:
|
|
55
|
+
for table in config.sor_tables:
|
|
56
|
+
ignore = set(config.sor_ignore_columns.get(table, []))
|
|
57
|
+
try:
|
|
58
|
+
rows = con.execute(f'SELECT * FROM "{table}"').fetchall()
|
|
59
|
+
except sqlite3.OperationalError:
|
|
60
|
+
out[table] = {}
|
|
61
|
+
continue
|
|
62
|
+
data = {}
|
|
63
|
+
for r in rows:
|
|
64
|
+
d = dict(r)
|
|
65
|
+
for col in ignore & set(d): # keep a fingerprint instead of large content
|
|
66
|
+
val = d.pop(col)
|
|
67
|
+
d[col + "_sha256"] = hashlib.sha256(val if isinstance(val, bytes) else str(val).encode()).hexdigest()[:16]
|
|
68
|
+
data[str(d.get("id", json.dumps(d, sort_keys=True, default=str)))] = d
|
|
69
|
+
out[table] = data
|
|
70
|
+
finally:
|
|
71
|
+
con.close()
|
|
72
|
+
return out
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def diff(before: dict, after: dict) -> dict[str, Any]:
|
|
76
|
+
"""Per-table added / removed / changed rows between two snapshots."""
|
|
77
|
+
result: dict[str, Any] = {}
|
|
78
|
+
for table in sorted(set(before) | set(after)):
|
|
79
|
+
b, a = before.get(table, {}), after.get(table, {})
|
|
80
|
+
added = [a[k] for k in a.keys() - b.keys()]
|
|
81
|
+
removed = [b[k] for k in b.keys() - a.keys()]
|
|
82
|
+
changed = {}
|
|
83
|
+
for k in a.keys() & b.keys():
|
|
84
|
+
cols = {c: [b[k].get(c), a[k].get(c)] for c in set(a[k]) | set(b[k]) if b[k].get(c) != a[k].get(c)}
|
|
85
|
+
if cols:
|
|
86
|
+
changed[k] = cols
|
|
87
|
+
if added or removed or changed:
|
|
88
|
+
result[table] = {"added": added, "removed": removed, "changed": changed}
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def query(config: HarnessConfig, sql: str) -> Any:
|
|
93
|
+
"""One value (or rows) from the read-only replica, for state checks."""
|
|
94
|
+
con = sqlite3.connect(f"file:{config.sor_path.as_posix()}?mode=ro", uri=True)
|
|
95
|
+
try:
|
|
96
|
+
rows = con.execute(sql).fetchall()
|
|
97
|
+
finally:
|
|
98
|
+
con.close()
|
|
99
|
+
if len(rows) == 1 and len(rows[0]) == 1:
|
|
100
|
+
return rows[0][0]
|
|
101
|
+
return [list(r) for r in rows]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Test catalog: suites of ordered cases, each a turn in one conversation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Suite:
|
|
14
|
+
name: str
|
|
15
|
+
user: str
|
|
16
|
+
customer_id: str
|
|
17
|
+
starting_state: str
|
|
18
|
+
cases: list[dict[str, Any]]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_catalog(path: Path) -> list[Suite]:
|
|
22
|
+
data = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
|
|
23
|
+
return [Suite(s["suite"], s["user"], s["customer_id"], s.get("starting_state", ""), s["cases"]) for s in data["suites"]]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def select(suites: list[Suite], suite_names: list[str] | None = None, case_id: str | None = None) -> list[Suite]:
|
|
27
|
+
"""Choose suites; with case_id, run that case's suite up to and including the case (earlier turns set it up)."""
|
|
28
|
+
if case_id:
|
|
29
|
+
for s in suites:
|
|
30
|
+
ids = [c["id"] for c in s.cases]
|
|
31
|
+
if case_id in ids:
|
|
32
|
+
return [Suite(s.name, s.user, s.customer_id, s.starting_state, s.cases[: ids.index(case_id) + 1])]
|
|
33
|
+
raise KeyError(f"Case {case_id} not found in the catalog")
|
|
34
|
+
if suite_names:
|
|
35
|
+
wanted = {n.upper() for n in suite_names}
|
|
36
|
+
chosen = [s for s in suites if s.name.upper() in wanted]
|
|
37
|
+
missing = wanted - {s.name.upper() for s in chosen}
|
|
38
|
+
if missing:
|
|
39
|
+
raise KeyError(f"Unknown suite(s): {', '.join(sorted(missing))}")
|
|
40
|
+
return chosen
|
|
41
|
+
return suites
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def find_case(suites: list[Suite], case_id: str) -> tuple[Suite, dict[str, Any]]:
|
|
45
|
+
for s in suites:
|
|
46
|
+
for c in s.cases:
|
|
47
|
+
if c["id"] == case_id:
|
|
48
|
+
return s, c
|
|
49
|
+
raise KeyError(case_id)
|