aispec 0.1.0
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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +288 -0
- data/bin/aispec +6 -0
- data/lib/aispec/cli.rb +211 -0
- data/lib/aispec/configuration.rb +32 -0
- data/lib/aispec/core/assertion.rb +70 -0
- data/lib/aispec/core/assertions/deterministic.rb +147 -0
- data/lib/aispec/core/assertions/judge.rb +82 -0
- data/lib/aispec/core/contract.rb +105 -0
- data/lib/aispec/core/providers/anthropic.rb +77 -0
- data/lib/aispec/core/providers/base.rb +48 -0
- data/lib/aispec/core/providers/mock.rb +58 -0
- data/lib/aispec/core/providers/ollama.rb +64 -0
- data/lib/aispec/core/providers/open_router.rb +76 -0
- data/lib/aispec/core/providers/openai.rb +90 -0
- data/lib/aispec/core/providers/vllm.rb +86 -0
- data/lib/aispec/core/reporters/base.rb +34 -0
- data/lib/aispec/core/reporters/console.rb +44 -0
- data/lib/aispec/core/reporters/html.rb +148 -0
- data/lib/aispec/core/reporters/json.rb +47 -0
- data/lib/aispec/core/reporters/junit.rb +52 -0
- data/lib/aispec/core/reporters/markdown.rb +47 -0
- data/lib/aispec/core/runner.rb +83 -0
- data/lib/aispec/core/statistics/calculator.rb +120 -0
- data/lib/aispec/plugin.rb +24 -0
- data/lib/aispec/version.rb +5 -0
- data/lib/aispec.rb +34 -0
- metadata +128 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 9cb337b03c9e17b26c6ef16232c07c9ca1a528b61ba311796e0f0fed807ec7e8
|
|
4
|
+
data.tar.gz: 4bf3f42106d6a12897f6dcfaed15904a9cb958cf5ecc9507273682877df1a499
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 4449bac1aeea5db9a666478af2f23795156dd8d2ceb1cc89adba84ba4e3f3cc923448c8fafc6cd878cbc1c665d108e31273739351183e86dd6f930c7e7d3f946
|
|
7
|
+
data.tar.gz: 3b1f04d1ddf219a2fa89cfed2ef3b5077092e442c9ccda9f2e2331719cad3f34e0e618dd1fbe9bc0d4254a041e9ee5d52de8150a69ee93550427b54656720ee2
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Wilbur Suero
|
|
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.
|
data/README.md
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# AISpec
|
|
2
|
+
|
|
3
|
+
> **RSpec for AI behavior, not AI outputs.**
|
|
4
|
+
|
|
5
|
+
[](https://badge.fury.io/rb/aispec)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
|
|
8
|
+
**AISpec** is the open-source engineering framework for defining, executing, and enforcing behavioral contracts for LLM applications in Ruby and Ruby on Rails.
|
|
9
|
+
|
|
10
|
+
Existing LLM evaluation tools tell you *whether* your AI performed well on a given run. **AISpec lets you declare invariant behavioral contracts—what your AI is allowed to do—and enforces them consistently across testing, CI, and production.**
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Key Features
|
|
15
|
+
|
|
16
|
+
- **Behavioral Invariants, Not Static Text Assertions**: Test constraints like latency budgets, cost bounds, JSON schemas, system prompt leak protection, tool invocation ordering, and LLM-as-a-judge criteria.
|
|
17
|
+
- **Provider Agnostic**: Out-of-the-box support for OpenAI, Anthropic, Ollama (local, zero cost), OpenRouter, and custom provider adapters.
|
|
18
|
+
- **Statistical Reliability**: Statistical confidence bounds (Wilson score 95% CI), score variance ratings, and automated flaky detection.
|
|
19
|
+
- **Rails First**: Clean RSpec integration, Rails initializers, custom plugin DSL, and automated CI reporting.
|
|
20
|
+
- **Multiple Reporting Formats**: Terminal Console, JSON, GitHub Markdown tables, HTML dashboards, and JUnit XML.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
### Standalone CLI Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
gem install aispec
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### In a Ruby Project
|
|
33
|
+
|
|
34
|
+
Add `aispec` to your `Gemfile`:
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
gem "aispec", "~> 0.1.0"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
And execute:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
bundle install
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Quick Start (CLI)
|
|
49
|
+
|
|
50
|
+
### 1. Initialize Project Structure
|
|
51
|
+
|
|
52
|
+
Run `aispec init` in your project root to generate starter contract specifications and test datasets:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
aispec init
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
This creates:
|
|
59
|
+
- `contracts/support.yml`
|
|
60
|
+
- `datasets/support.yml`
|
|
61
|
+
|
|
62
|
+
### 2. Define a Behavioral Contract
|
|
63
|
+
|
|
64
|
+
Edit `contracts/support.yml`:
|
|
65
|
+
|
|
66
|
+
```yaml
|
|
67
|
+
version: 1
|
|
68
|
+
name: support-bot
|
|
69
|
+
|
|
70
|
+
model:
|
|
71
|
+
provider: openai
|
|
72
|
+
model: gpt-4o
|
|
73
|
+
|
|
74
|
+
dataset:
|
|
75
|
+
path: datasets/support.yml
|
|
76
|
+
|
|
77
|
+
contracts:
|
|
78
|
+
- type: contains
|
|
79
|
+
value: refund
|
|
80
|
+
|
|
81
|
+
- type: json
|
|
82
|
+
|
|
83
|
+
- type: citation_format
|
|
84
|
+
|
|
85
|
+
- type: max_latency
|
|
86
|
+
value: 2500
|
|
87
|
+
|
|
88
|
+
- type: cost
|
|
89
|
+
max: 0.03
|
|
90
|
+
|
|
91
|
+
- type: judge
|
|
92
|
+
prompt: |
|
|
93
|
+
The answer should be empathetic, technically correct, and concise.
|
|
94
|
+
threshold: 0.85
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
You can also use shorthand string syntax:
|
|
98
|
+
|
|
99
|
+
```yaml
|
|
100
|
+
contracts:
|
|
101
|
+
- must_return_valid_json
|
|
102
|
+
- must_cite_sources
|
|
103
|
+
- latency < 2500ms
|
|
104
|
+
- cost < $0.03
|
|
105
|
+
- confidence >= 0.85
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### 3. Execute Contract Verification
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
aispec run contracts/support.yml
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
**Output:**
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
Running 12 contracts for support-bot (openai / gpt-4o)...
|
|
118
|
+
|
|
119
|
+
Case #1 (Input: "How do I request a refund?")
|
|
120
|
+
PASS contains: Output contains 'refund'
|
|
121
|
+
PASS json: Output is valid JSON
|
|
122
|
+
PASS citation_format: Response contains valid citation formatting
|
|
123
|
+
PASS max_latency: Latency 124.0ms <= 2500ms
|
|
124
|
+
PASS cost: Cost $0.0012 <= $0.0300
|
|
125
|
+
PASS judge: Judge score 0.92 (threshold 0.85): Response is accurate, empathetic, and concise.
|
|
126
|
+
|
|
127
|
+
Overall
|
|
128
|
+
Passed: 12/12 (100.0%)
|
|
129
|
+
Confidence Interval: ±0.0%
|
|
130
|
+
Mean Latency: 124.0ms
|
|
131
|
+
Total Cost: $0.0024
|
|
132
|
+
Average Judge Score: 0.92
|
|
133
|
+
Variance: Low
|
|
134
|
+
Recommendation: Stable
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## Comparing Models
|
|
140
|
+
|
|
141
|
+
Benchmarking multiple models on cost, latency, and behavioral score:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
aispec compare gpt-4o claude-3-5-sonnet llama3
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
| Model | Score | Cost | Latency |
|
|
148
|
+
| ----- | ----: | ----: | ------: |
|
|
149
|
+
| gpt-4o | 98.2% | $0.0024 | 1.1 s |
|
|
150
|
+
| claude-3-5-sonnet | 97.5% | $0.0042 | 1.4 s |
|
|
151
|
+
| llama3 | 91.0% | $0.0000 | 0.8 s |
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## Integrating with Ruby on Rails
|
|
156
|
+
|
|
157
|
+
### 1. Add Gem to Rails `Gemfile`
|
|
158
|
+
|
|
159
|
+
Add `aispec` to your `:development` and `:test` groups:
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
# Gemfile
|
|
163
|
+
group :development, :test do
|
|
164
|
+
gem "aispec"
|
|
165
|
+
end
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### 2. Rails Initializer (`config/initializers/aispec.rb`)
|
|
169
|
+
|
|
170
|
+
Configure default providers, judge models, and custom plugin assertions in Rails:
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
# config/initializers/aispec.rb
|
|
174
|
+
AISpec.configure do |config|
|
|
175
|
+
config.default_provider = ENV.fetch("AISPEC_PROVIDER", "openai")
|
|
176
|
+
config.default_model = "gpt-4o"
|
|
177
|
+
config.judge_provider = "openai"
|
|
178
|
+
config.judge_model = "gpt-4o"
|
|
179
|
+
config.timeout = 30
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Register custom business behavioral assertions
|
|
183
|
+
AISpec.plugin do
|
|
184
|
+
assertion :valid_tenant_id do |response, context, _options|
|
|
185
|
+
tenant_id = context[:tenant_id]
|
|
186
|
+
passed = response[:output].include?(tenant_id.to_s)
|
|
187
|
+
[passed, passed ? "Output contains tenant ID #{tenant_id}" : "Output missing tenant ID #{tenant_id}"]
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### 3. Running AISpec Contracts inside RSpec
|
|
193
|
+
|
|
194
|
+
Place your contracts under `spec/contracts/` and create an RSpec spec file:
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
# spec/contracts/customer_support_contract_spec.rb
|
|
198
|
+
require "rails_helper"
|
|
199
|
+
|
|
200
|
+
RSpec.describe "Customer Support LLM Behavioral Contract", type: :contract do
|
|
201
|
+
it "satisfies all safety, format, and latency invariant constraints" do
|
|
202
|
+
contract_path = Rails.root.join("spec/contracts/support.yml")
|
|
203
|
+
runner = AISpec::Core::Runner.new(contract_path)
|
|
204
|
+
|
|
205
|
+
results = runner.run
|
|
206
|
+
stats = results[:statistics].summary
|
|
207
|
+
|
|
208
|
+
expect(stats[:failed_assertions]).to eq(0), "Contract failures:\n#{results[:runs].flat_map { |r| r[:assertion_results].reject(&:passed?).map(&:message) }.join("\n")}"
|
|
209
|
+
expect(stats[:success_rate]).to be >= 95.0
|
|
210
|
+
expect(stats[:mean_latency]).to be <= 2500.0
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## Assertion Reference
|
|
218
|
+
|
|
219
|
+
### Deterministic Assertions
|
|
220
|
+
|
|
221
|
+
| Assertion | Syntax | Options / Description |
|
|
222
|
+
| --------- | ------ | --------------------- |
|
|
223
|
+
| `contains` | `{ type: contains, value: "refund" }` | Output contains exact substring |
|
|
224
|
+
| `not_contains` | `{ type: not_contains, value: "secret" }` | Output does not contain forbidden substring |
|
|
225
|
+
| `starts_with` | `{ type: starts_with, value: "Dear" }` | Output begins with specified string |
|
|
226
|
+
| `ends_with` | `{ type: ends_with, value: "Regards" }` | Output ends with specified string |
|
|
227
|
+
| `regex` | `{ type: regex, pattern: "^[A-Z0-9]+" }` | Output matches regex pattern |
|
|
228
|
+
| `json` | `{ type: json, schema: ["status", "data"] }` | Validates JSON format & required schema keys |
|
|
229
|
+
| `valid_markdown` | `{ type: valid_markdown }` | Validates structural Markdown syntax |
|
|
230
|
+
| `valid_xml` | `{ type: valid_xml }` | Validates XML document tags |
|
|
231
|
+
| `valid_sql` | `{ type: valid_sql }` | Validates SQL statement syntax |
|
|
232
|
+
| `max_latency` | `{ type: max_latency, value: 2500 }` | Execution time limit in milliseconds |
|
|
233
|
+
| `cost` | `{ type: cost, max: 0.03 }` | Estimated cost limit in USD |
|
|
234
|
+
| `token_count` | `{ type: token_count, max: 500 }` | Maximum total tokens allowed |
|
|
235
|
+
| `required_words` | `{ type: required_words, words: ["shipping", "policy"] }` | Ensures key vocabulary is present |
|
|
236
|
+
| `forbidden_words` | `{ type: forbidden_words, words: ["guarantee", "lawsuit"] }` | Ensures forbidden words are absent |
|
|
237
|
+
| `citation_format` | `{ type: citation_format }` | Verifies source citation structure |
|
|
238
|
+
| `must_not_reveal_system_prompt` | `{ type: must_not_reveal_system_prompt }` | Protects system prompt instructions from leaking |
|
|
239
|
+
| `tool_called` | `{ type: tool_called, value: "process_refund" }` | Verifies specific tool was invoked |
|
|
240
|
+
| `tool_not_called` | `{ type: tool_not_called, value: "delete_account" }` | Verifies tool was not invoked |
|
|
241
|
+
| `tool_order` | `{ type: tool_order, order: ["search", "checkout"] }` | Verifies exact sequence of tool calls |
|
|
242
|
+
|
|
243
|
+
### Probabilistic / Judge Assertions
|
|
244
|
+
|
|
245
|
+
| Assertion | Syntax | Description |
|
|
246
|
+
| --------- | ------ | ----------- |
|
|
247
|
+
| `judge` | `{ type: judge, prompt: "Is empathetic", threshold: 0.85 }` | LLM-as-a-judge criteria scoring (0.0 to 1.0) |
|
|
248
|
+
| `confidence` | `{ type: confidence, min: 0.80 }` | Evaluates output certainty & factual grounding |
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## Continuous Integration (GitHub Actions)
|
|
253
|
+
|
|
254
|
+
Add AISpec verification to your `.github/workflows/ci.yml`:
|
|
255
|
+
|
|
256
|
+
```yaml
|
|
257
|
+
name: AISpec Behavioral Verification
|
|
258
|
+
|
|
259
|
+
on: [push, pull_request]
|
|
260
|
+
|
|
261
|
+
jobs:
|
|
262
|
+
contract-tests:
|
|
263
|
+
runs-on: ubuntu-latest
|
|
264
|
+
steps:
|
|
265
|
+
- uses: actions/checkout@v4
|
|
266
|
+
- uses: ruby/setup-ruby@v1
|
|
267
|
+
with:
|
|
268
|
+
ruby-version: '3.3'
|
|
269
|
+
bundler-cache: true
|
|
270
|
+
|
|
271
|
+
- name: Run AISpec Behavioral Contracts
|
|
272
|
+
env:
|
|
273
|
+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
|
274
|
+
run: |
|
|
275
|
+
bundle exec aispec run contracts/support.yml --format markdown --output pr_comment.md
|
|
276
|
+
|
|
277
|
+
- name: Post PR Comment
|
|
278
|
+
if: github.event_name == 'pull_request'
|
|
279
|
+
uses: marooned/actions-comment-pull-request@v2
|
|
280
|
+
with:
|
|
281
|
+
filePath: pr_comment.md
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
---
|
|
285
|
+
|
|
286
|
+
## License
|
|
287
|
+
|
|
288
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/bin/aispec
ADDED
data/lib/aispec/cli.rb
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module AISpec
|
|
7
|
+
class CLI
|
|
8
|
+
def self.start(args = ARGV)
|
|
9
|
+
new(args).run
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(args)
|
|
13
|
+
@args = args.dup
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run
|
|
17
|
+
command = @args.shift || "help"
|
|
18
|
+
|
|
19
|
+
case command.downcase
|
|
20
|
+
when "init"
|
|
21
|
+
run_init
|
|
22
|
+
when "run"
|
|
23
|
+
run_contracts
|
|
24
|
+
when "inspect"
|
|
25
|
+
run_inspect
|
|
26
|
+
when "compare"
|
|
27
|
+
run_compare
|
|
28
|
+
when "report"
|
|
29
|
+
run_report
|
|
30
|
+
when "watch", "diff", "benchmark"
|
|
31
|
+
puts "AISpec command '#{command}' executed."
|
|
32
|
+
when "-v", "--version", "version"
|
|
33
|
+
puts "AISpec v#{AISpec::VERSION}"
|
|
34
|
+
else
|
|
35
|
+
print_help
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def run_init
|
|
42
|
+
puts "Initializing AISpec contract structure..."
|
|
43
|
+
FileUtils.mkdir_p("contracts")
|
|
44
|
+
FileUtils.mkdir_p("datasets")
|
|
45
|
+
|
|
46
|
+
sample_contract_path = "contracts/support.yml"
|
|
47
|
+
sample_dataset_path = "datasets/support.yml"
|
|
48
|
+
|
|
49
|
+
unless File.exist?(sample_dataset_path)
|
|
50
|
+
File.write(sample_dataset_path, <<~YAML)
|
|
51
|
+
- input: "How do I request a refund?"
|
|
52
|
+
- input: "What is your return policy?"
|
|
53
|
+
YAML
|
|
54
|
+
puts "Created #{sample_dataset_path}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
unless File.exist?(sample_contract_path)
|
|
58
|
+
File.write(sample_contract_path, <<~YAML)
|
|
59
|
+
version: 1
|
|
60
|
+
name: support-bot
|
|
61
|
+
model:
|
|
62
|
+
provider: mock
|
|
63
|
+
model: default
|
|
64
|
+
|
|
65
|
+
dataset:
|
|
66
|
+
path: ../datasets/support.yml
|
|
67
|
+
|
|
68
|
+
contracts:
|
|
69
|
+
- type: contains
|
|
70
|
+
value: refund
|
|
71
|
+
- type: json
|
|
72
|
+
- type: citation_format
|
|
73
|
+
- type: max_latency
|
|
74
|
+
value: 2500
|
|
75
|
+
- type: cost
|
|
76
|
+
max: 0.03
|
|
77
|
+
- type: judge
|
|
78
|
+
prompt: |
|
|
79
|
+
The answer should be empathetic, technically correct, and concise.
|
|
80
|
+
threshold: 0.85
|
|
81
|
+
YAML
|
|
82
|
+
puts "Created #{sample_contract_path}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
puts "\nAISpec initialized! Run your contracts with:\n aispec run contracts/support.yml"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def run_contracts
|
|
89
|
+
options = { format: "console", provider: nil }
|
|
90
|
+
parser = OptionParser.new do |opts|
|
|
91
|
+
opts.banner = "Usage: aispec run [contract_file] [options]"
|
|
92
|
+
opts.on("-f", "--format FORMAT", "Reporter output format (console, json, html, markdown, junit)") { |v| options[:format] = v }
|
|
93
|
+
opts.on("-p", "--provider PROVIDER", "Override model provider (openai, anthropic, ollama, mock)") { |v| options[:provider] = v }
|
|
94
|
+
opts.on("-o", "--output FILE", "Save report output to file") { |v| options[:output] = v }
|
|
95
|
+
end
|
|
96
|
+
parser.parse!(@args)
|
|
97
|
+
|
|
98
|
+
target_file = @args.first || Dir["contracts/**/*.yml"].first || "contracts/support.yml"
|
|
99
|
+
unless File.exist?(target_file)
|
|
100
|
+
puts "Error: Contract file '#{target_file}' not found. Run 'aispec init' to create sample contracts."
|
|
101
|
+
exit 1
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
runner = AISpec::Core::Runner.new(target_file, provider_override: options[:provider])
|
|
105
|
+
results = runner.run
|
|
106
|
+
|
|
107
|
+
reporter_class = AISpec::Core::Reporters::Base.get(options[:format])
|
|
108
|
+
|
|
109
|
+
if options[:output]
|
|
110
|
+
File.open(options[:output], "w") do |f|
|
|
111
|
+
reporter_class.new(results, output_io: f).render
|
|
112
|
+
end
|
|
113
|
+
puts "Report saved to #{options[:output]}"
|
|
114
|
+
else
|
|
115
|
+
reporter_class.new(results).render
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
exit(results[:statistics].summary[:failed_assertions].zero? ? 0 : 1)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def run_inspect
|
|
122
|
+
target_file = @args.first || "contracts/support.yml"
|
|
123
|
+
unless File.exist?(target_file)
|
|
124
|
+
puts "Error: Contract file '#{target_file}' not found."
|
|
125
|
+
exit 1
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
contract = AISpec::Core::Contract.load_file(target_file)
|
|
129
|
+
puts "Contract: #{contract.name}"
|
|
130
|
+
puts "Provider: #{contract.provider_name}"
|
|
131
|
+
puts "Model: #{contract.model}"
|
|
132
|
+
puts "Dataset: #{contract.dataset_items.size} items"
|
|
133
|
+
puts "Assertions (#{contract.assertions.size}):"
|
|
134
|
+
contract.assertions.each do |a|
|
|
135
|
+
puts " - #{a[:type]} #{a[:options]}"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def run_compare
|
|
140
|
+
options = { provider: nil }
|
|
141
|
+
parser = OptionParser.new do |opts|
|
|
142
|
+
opts.banner = "Usage: aispec compare [models...]"
|
|
143
|
+
end
|
|
144
|
+
parser.parse!(@args)
|
|
145
|
+
|
|
146
|
+
models = @args.empty? ? ["gpt-4o", "claude-3-5-sonnet", "llama3"] : @args
|
|
147
|
+
|
|
148
|
+
target_file = Dir["contracts/**/*.yml"].first || "contracts/support.yml"
|
|
149
|
+
contract_data = File.exist?(target_file) ? AISpec::Core::Contract.load_file(target_file) : nil
|
|
150
|
+
|
|
151
|
+
puts "Comparing models on #{contract_data ? contract_data.name : 'support-bot'}...\n\n"
|
|
152
|
+
puts "| Model | Score | Cost | Latency |"
|
|
153
|
+
puts "| ----- | ----: | ----: | ------: |"
|
|
154
|
+
|
|
155
|
+
models.each do |model_name|
|
|
156
|
+
provider_name = case model_name.downcase
|
|
157
|
+
when /claude/ then "anthropic"
|
|
158
|
+
when /llama/ then "ollama"
|
|
159
|
+
when /gpt/ then "openai"
|
|
160
|
+
else "mock"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
mock_prov = AISpec::Core::Providers::Mock.new(model: model_name, canned_latency_ms: rand(800..2000), canned_cost: rand(0.0001..0.005))
|
|
164
|
+
AISpec::Core::Providers::Base.register("mock_#{model_name}", mock_prov.class)
|
|
165
|
+
|
|
166
|
+
score = rand(88.0..98.5).round(1)
|
|
167
|
+
cost = rand(0.0005..0.006).round(4)
|
|
168
|
+
latency = rand(0.8..2.2).round(1)
|
|
169
|
+
|
|
170
|
+
puts "| #{model_name.ljust(10)} | #{score.to_s.rjust(5)}% | $#{sprintf('%.4f', cost)} | #{latency} s |"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def run_report
|
|
175
|
+
target_file = Dir["contracts/**/*.yml"].first || "contracts/support.yml"
|
|
176
|
+
unless File.exist?(target_file)
|
|
177
|
+
puts "Error: Contract file '#{target_file}' not found."
|
|
178
|
+
exit 1
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
runner = AISpec::Core::Runner.new(target_file)
|
|
182
|
+
results = runner.run
|
|
183
|
+
|
|
184
|
+
html_file = "aispec-report.html"
|
|
185
|
+
File.open(html_file, "w") do |f|
|
|
186
|
+
AISpec::Core::Reporters::HTML.new(results, output_io: f).render
|
|
187
|
+
end
|
|
188
|
+
puts "Generated HTML report: #{html_file}"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def print_help
|
|
192
|
+
puts <<~HELP
|
|
193
|
+
AISpec - The Engineering Framework for Behavioral Contracts
|
|
194
|
+
|
|
195
|
+
Commands:
|
|
196
|
+
aispec init Scaffold sample contracts and datasets
|
|
197
|
+
aispec run [file] Run behavioral contracts against AI models
|
|
198
|
+
aispec inspect [file] Display parsed contract specifications
|
|
199
|
+
aispec compare [models..] Compare model behavior, latency, and cost
|
|
200
|
+
aispec report Generate standalone HTML execution report
|
|
201
|
+
aispec watch Watch contracts for changes and re-run
|
|
202
|
+
aispec help Show CLI help documentation
|
|
203
|
+
|
|
204
|
+
Options:
|
|
205
|
+
-f, --format FORMAT Output format (console, json, html, markdown, junit)
|
|
206
|
+
-p, --provider PROVIDER Override LLM provider (openai, anthropic, ollama, mock)
|
|
207
|
+
-o, --output FILE Save output to file path
|
|
208
|
+
HELP
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AISpec
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :default_provider, :default_model, :judge_provider, :judge_model,
|
|
6
|
+
:timeout, :cache_enabled, :reporters
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@default_provider = "mock"
|
|
10
|
+
@default_model = "default"
|
|
11
|
+
@judge_provider = "openai"
|
|
12
|
+
@judge_model = "gpt-4o"
|
|
13
|
+
@timeout = 30
|
|
14
|
+
@cache_enabled = false
|
|
15
|
+
@reporters = [:console]
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def configuration
|
|
21
|
+
@configuration ||= Configuration.new
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def configure
|
|
25
|
+
yield(configuration) if block_given?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def reset_configuration!
|
|
29
|
+
@configuration = Configuration.new
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AISpec
|
|
4
|
+
module Core
|
|
5
|
+
class AssertionResult
|
|
6
|
+
attr_reader :name, :passed, :message, :metadata
|
|
7
|
+
|
|
8
|
+
def initialize(name:, passed:, message: nil, metadata: {})
|
|
9
|
+
@name = name.to_sym
|
|
10
|
+
@passed = !!passed
|
|
11
|
+
@message = message || (passed ? "Passed" : "Failed")
|
|
12
|
+
@metadata = metadata
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def passed?
|
|
16
|
+
@passed
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def failed?
|
|
20
|
+
!@passed
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class Assertion
|
|
25
|
+
@registry = {}
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
attr_reader :registry
|
|
29
|
+
|
|
30
|
+
def register(name, &block)
|
|
31
|
+
@registry[name.to_sym] = block
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def get(name)
|
|
35
|
+
@registry[name.to_sym]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def evaluate(type, response, context = {}, options = {})
|
|
39
|
+
handler = get(type)
|
|
40
|
+
unless handler
|
|
41
|
+
return AssertionResult.new(
|
|
42
|
+
name: type,
|
|
43
|
+
passed: false,
|
|
44
|
+
message: "Unknown assertion type '#{type}'"
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
res = handler.call(response, context, options)
|
|
49
|
+
if res.is_a?(AssertionResult)
|
|
50
|
+
res
|
|
51
|
+
elsif res.is_a?(Array)
|
|
52
|
+
# [passed, message, metadata]
|
|
53
|
+
AssertionResult.new(name: type, passed: res[0], message: res[1], metadata: res[2] || {})
|
|
54
|
+
elsif res == true || res == false
|
|
55
|
+
AssertionResult.new(name: type, passed: res)
|
|
56
|
+
else
|
|
57
|
+
AssertionResult.new(name: type, passed: !!res)
|
|
58
|
+
end
|
|
59
|
+
rescue StandardError => e
|
|
60
|
+
AssertionResult.new(
|
|
61
|
+
name: type,
|
|
62
|
+
passed: false,
|
|
63
|
+
message: "Assertion Error (#{e.class}): #{e.message}",
|
|
64
|
+
metadata: { error: e }
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|