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
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module AISpec
|
|
8
|
+
module Core
|
|
9
|
+
module Providers
|
|
10
|
+
class OpenAI < Base
|
|
11
|
+
BASE_URI = "https://api.openai.com/v1/chat/completions"
|
|
12
|
+
|
|
13
|
+
def initialize(model: "gpt-4o", api_key: nil, **options)
|
|
14
|
+
super(model: model, **options)
|
|
15
|
+
@api_key = api_key || ENV["OPENAI_API_KEY"]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def generate(prompt, system_prompt: nil, temperature: 0.7, **_params)
|
|
19
|
+
return fallback_mock_if_no_key(prompt) unless @api_key && !@api_key.empty?
|
|
20
|
+
|
|
21
|
+
messages = []
|
|
22
|
+
messages << { role: "system", content: system_prompt } if system_prompt
|
|
23
|
+
messages << { role: "user", content: prompt }
|
|
24
|
+
|
|
25
|
+
payload = {
|
|
26
|
+
model: @model || "gpt-4o",
|
|
27
|
+
messages: messages,
|
|
28
|
+
temperature: temperature
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
uri = URI.parse(BASE_URI)
|
|
32
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
33
|
+
|
|
34
|
+
req = Net::HTTP::Post.new(uri.path, {
|
|
35
|
+
"Content-Type" => "application/json",
|
|
36
|
+
"Authorization" => "Bearer #{@api_key}"
|
|
37
|
+
})
|
|
38
|
+
req.body = JSON.generate(payload)
|
|
39
|
+
|
|
40
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
41
|
+
http.use_ssl = true
|
|
42
|
+
http.read_timeout = AISpec.configuration.timeout
|
|
43
|
+
|
|
44
|
+
res = http.request(req)
|
|
45
|
+
elapsed_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000.0
|
|
46
|
+
|
|
47
|
+
if res.is_a?(Net::HTTPSuccess)
|
|
48
|
+
data = JSON.parse(res.body)
|
|
49
|
+
output = data.dig("choices", 0, "message", "content") || ""
|
|
50
|
+
prompt_tokens = data.dig("usage", "prompt_tokens") || 0
|
|
51
|
+
comp_tokens = data.dig("usage", "completion_tokens") || 0
|
|
52
|
+
total_tokens = data.dig("usage", "total_tokens") || (prompt_tokens + comp_tokens)
|
|
53
|
+
|
|
54
|
+
cost = estimate_cost(@model, prompt_tokens, comp_tokens)
|
|
55
|
+
|
|
56
|
+
build_response(
|
|
57
|
+
output: output,
|
|
58
|
+
latency_ms: elapsed_ms,
|
|
59
|
+
tokens: total_tokens,
|
|
60
|
+
cost: cost,
|
|
61
|
+
raw: data
|
|
62
|
+
)
|
|
63
|
+
else
|
|
64
|
+
raise "OpenAI API Error (#{res.code}): #{res.body}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def fallback_mock_if_no_key(prompt)
|
|
71
|
+
Mock.new(model: @model).generate(prompt)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def estimate_cost(model_name, input_tokens, output_tokens)
|
|
75
|
+
# Estimated standard pricing per 1k tokens
|
|
76
|
+
case model_name.to_s.downcase
|
|
77
|
+
when /gpt-4o/
|
|
78
|
+
(input_tokens * 0.000005) + (output_tokens * 0.000015)
|
|
79
|
+
when /gpt-4/
|
|
80
|
+
(input_tokens * 0.00003) + (output_tokens * 0.00006)
|
|
81
|
+
else
|
|
82
|
+
(input_tokens * 0.0000015) + (output_tokens * 0.000002)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
AISpec::Core::Providers::Base.register("openai", AISpec::Core::Providers::OpenAI)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module AISpec
|
|
8
|
+
module Core
|
|
9
|
+
module Providers
|
|
10
|
+
class VLLM < Base
|
|
11
|
+
def initialize(model: "llama-4", endpoint: nil, api_key: nil, **options)
|
|
12
|
+
super(model: model, **options)
|
|
13
|
+
@endpoint = endpoint || ENV["VLLM_API_BASE"] || ENV["VLLM_HOST"] || "http://localhost:8000/v1/chat/completions"
|
|
14
|
+
@api_key = api_key || ENV["VLLM_API_KEY"]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def generate(prompt, system_prompt: nil, temperature: 0.7, **_params)
|
|
18
|
+
uri = URI.parse(@endpoint)
|
|
19
|
+
|
|
20
|
+
messages = []
|
|
21
|
+
messages << { role: "system", content: system_prompt } if system_prompt
|
|
22
|
+
messages << { role: "user", content: prompt }
|
|
23
|
+
|
|
24
|
+
payload = {
|
|
25
|
+
model: @model || "llama-4",
|
|
26
|
+
messages: messages,
|
|
27
|
+
temperature: temperature
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
31
|
+
|
|
32
|
+
headers = { "Content-Type" => "application/json" }
|
|
33
|
+
headers["Authorization"] = "Bearer #{@api_key}" if @api_key && !@api_key.empty?
|
|
34
|
+
|
|
35
|
+
req = Net::HTTP::Post.new(uri.path.empty? ? "/v1/chat/completions" : uri.path, headers)
|
|
36
|
+
req.body = JSON.generate(payload)
|
|
37
|
+
|
|
38
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
39
|
+
http.use_ssl = uri.scheme == "https"
|
|
40
|
+
http.read_timeout = AISpec.configuration.timeout
|
|
41
|
+
|
|
42
|
+
begin
|
|
43
|
+
res = http.request(req)
|
|
44
|
+
elapsed_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000.0
|
|
45
|
+
|
|
46
|
+
if res.is_a?(Net::HTTPSuccess)
|
|
47
|
+
data = JSON.parse(res.body)
|
|
48
|
+
output = data.dig("choices", 0, "message", "content") || ""
|
|
49
|
+
prompt_tokens = data.dig("usage", "prompt_tokens") || 0
|
|
50
|
+
comp_tokens = data.dig("usage", "completion_tokens") || 0
|
|
51
|
+
total_tokens = data.dig("usage", "total_tokens") || (prompt_tokens + comp_tokens)
|
|
52
|
+
|
|
53
|
+
build_response(
|
|
54
|
+
output: output,
|
|
55
|
+
latency_ms: elapsed_ms,
|
|
56
|
+
tokens: total_tokens,
|
|
57
|
+
cost: 0.0, # Local/Self-hosted vLLM infrastructure
|
|
58
|
+
raw: data
|
|
59
|
+
)
|
|
60
|
+
else
|
|
61
|
+
raise "vLLM API Error (#{res.code}): #{res.body}"
|
|
62
|
+
end
|
|
63
|
+
rescue Exception => e
|
|
64
|
+
# If network connection fails or VCR/WebMock blocks connection in tests without active server, fallback to mock
|
|
65
|
+
if is_network_or_mock_error?(e)
|
|
66
|
+
Mock.new(model: @model).generate(prompt)
|
|
67
|
+
else
|
|
68
|
+
raise e
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def is_network_or_mock_error?(err)
|
|
76
|
+
err.is_a?(Errno::ECONNREFUSED) ||
|
|
77
|
+
err.is_a?(SocketError) ||
|
|
78
|
+
err.class.name.include?("WebMock") ||
|
|
79
|
+
err.class.name.include?("VCR")
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
AISpec::Core::Providers::Base.register("vllm", AISpec::Core::Providers::VLLM)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AISpec
|
|
4
|
+
module Core
|
|
5
|
+
module Reporters
|
|
6
|
+
class Base
|
|
7
|
+
@registry = {}
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
attr_reader :registry
|
|
11
|
+
|
|
12
|
+
def register(name, reporter_class)
|
|
13
|
+
@registry[name.to_s.downcase] = reporter_class
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def get(name)
|
|
17
|
+
@registry[name.to_s.downcase] || Console
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
attr_reader :results, :output_io
|
|
22
|
+
|
|
23
|
+
def initialize(results, output_io: $stdout)
|
|
24
|
+
@results = results
|
|
25
|
+
@output_io = output_io
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def render
|
|
29
|
+
raise NotImplementedError, "#{self.class.name} must implement #render"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AISpec
|
|
4
|
+
module Core
|
|
5
|
+
module Reporters
|
|
6
|
+
class Console < Base
|
|
7
|
+
def render
|
|
8
|
+
contract = results[:contract]
|
|
9
|
+
stats = results[:statistics]
|
|
10
|
+
summary = stats.summary
|
|
11
|
+
|
|
12
|
+
output_io.puts "Running #{summary[:total_assertions]} contracts for #{contract.name} (#{results[:provider_name]} / #{results[:model]})...\n\n"
|
|
13
|
+
|
|
14
|
+
results[:runs].each_with_index do |run, idx|
|
|
15
|
+
output_io.puts "Case ##{idx + 1} (Input: \"#{truncate(run[:input])}\")" if results[:runs].size > 1
|
|
16
|
+
run[:assertion_results].each do |ar|
|
|
17
|
+
status = ar.passed? ? "\e[32mPASS\e[0m" : "\e[31mFAIL\e[0m"
|
|
18
|
+
output_io.puts " #{status} #{ar.name}: #{ar.message}"
|
|
19
|
+
end
|
|
20
|
+
output_io.puts ""
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
output_io.puts "Overall"
|
|
24
|
+
output_io.puts " Passed: #{summary[:passed_assertions]}/#{summary[:total_assertions]} (#{summary[:success_rate]}%)"
|
|
25
|
+
output_io.puts " Confidence Interval: ±#{summary[:confidence_interval]}%"
|
|
26
|
+
output_io.puts " Mean Latency: #{summary[:mean_latency]}ms"
|
|
27
|
+
output_io.puts " Total Cost: $#{sprintf('%.4f', summary[:total_cost])}"
|
|
28
|
+
output_io.puts " Average Judge Score: #{summary[:average_judge_score] || 'N/A'}"
|
|
29
|
+
output_io.puts " Variance: #{summary[:variance]}"
|
|
30
|
+
output_io.puts " Recommendation: \e[1m#{summary[:recommendation]}\e[0m\n"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def truncate(str, max = 50)
|
|
36
|
+
s = str.to_s
|
|
37
|
+
s.length > max ? "#{s[0...max]}..." : s
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
AISpec::Core::Reporters::Base.register("console", AISpec::Core::Reporters::Console)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AISpec
|
|
4
|
+
module Core
|
|
5
|
+
module Reporters
|
|
6
|
+
class HTML < Base
|
|
7
|
+
def render
|
|
8
|
+
contract = results[:contract]
|
|
9
|
+
stats = results[:statistics]
|
|
10
|
+
summary = stats.summary
|
|
11
|
+
|
|
12
|
+
html = <<~HTML
|
|
13
|
+
<!DOCTYPE html>
|
|
14
|
+
<html lang="en">
|
|
15
|
+
<head>
|
|
16
|
+
<meta charset="UTF-8">
|
|
17
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
18
|
+
<title>AISpec Contract Verification Report - #{contract.name}</title>
|
|
19
|
+
<style>
|
|
20
|
+
:root {
|
|
21
|
+
--bg: #0f172a;
|
|
22
|
+
--card-bg: #1e293b;
|
|
23
|
+
--text: #f8fafc;
|
|
24
|
+
--text-muted: #94a3b8;
|
|
25
|
+
--accent: #6366f1;
|
|
26
|
+
--success: #22c55e;
|
|
27
|
+
--danger: #ef4444;
|
|
28
|
+
--border: #334155;
|
|
29
|
+
}
|
|
30
|
+
body {
|
|
31
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
32
|
+
background-color: var(--bg);
|
|
33
|
+
color: var(--text);
|
|
34
|
+
margin: 0;
|
|
35
|
+
padding: 2rem;
|
|
36
|
+
}
|
|
37
|
+
.container { max-width: 1100px; margin: 0 auto; }
|
|
38
|
+
header { margin-bottom: 2rem; border-bottom: 1px solid var(--border); padding-bottom: 1rem; }
|
|
39
|
+
h1 { margin: 0 0 0.5rem 0; font-size: 2rem; }
|
|
40
|
+
.subtitle { color: var(--text-muted); font-size: 1.1rem; }
|
|
41
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; margin-bottom: 2rem; }
|
|
42
|
+
.card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; padding: 1.25rem; }
|
|
43
|
+
.card-title { color: var(--text-muted); font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
44
|
+
.card-value { font-size: 1.8rem; font-weight: 700; margin-top: 0.5rem; }
|
|
45
|
+
.badge-pass { color: var(--success); }
|
|
46
|
+
.badge-fail { color: var(--danger); }
|
|
47
|
+
section { background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; padding: 1.5rem; margin-bottom: 1.5rem; }
|
|
48
|
+
h2 { margin-top: 0; font-size: 1.3rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; }
|
|
49
|
+
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
|
|
50
|
+
th, td { text-align: left; padding: 0.75rem; border-bottom: 1px solid var(--border); }
|
|
51
|
+
th { color: var(--text-muted); font-weight: 600; font-size: 0.9rem; }
|
|
52
|
+
pre { background: #090d16; padding: 1rem; border-radius: 8px; overflow-x: auto; font-size: 0.9rem; color: #e2e8f0; }
|
|
53
|
+
</style>
|
|
54
|
+
</head>
|
|
55
|
+
<body>
|
|
56
|
+
<div class="container">
|
|
57
|
+
<header>
|
|
58
|
+
<h1>AISpec Behavioral Contract Report</h1>
|
|
59
|
+
<div class="subtitle">Contract: <strong>#{contract.name}</strong> | Provider: <strong>#{results[:provider_name]}</strong> | Model: <strong>#{results[:model]}</strong></div>
|
|
60
|
+
</header>
|
|
61
|
+
|
|
62
|
+
<div class="grid">
|
|
63
|
+
<div class="card">
|
|
64
|
+
<div class="card-title">Behavior Score</div>
|
|
65
|
+
<div class="card-value #{summary[:success_rate] >= 90 ? 'badge-pass' : 'badge-fail'}">#{summary[:success_rate]}%</div>
|
|
66
|
+
<div style="font-size:0.8rem; color:var(--text-muted);">CI: ±#{summary[:confidence_interval]}%</div>
|
|
67
|
+
</div>
|
|
68
|
+
<div class="card">
|
|
69
|
+
<div class="card-title">Assertions</div>
|
|
70
|
+
<div class="card-value">#{summary[:passed_assertions]} / #{summary[:total_assertions]}</div>
|
|
71
|
+
</div>
|
|
72
|
+
<div class="card">
|
|
73
|
+
<div class="card-title">Mean Latency</div>
|
|
74
|
+
<div class="card-value">#{summary[:mean_latency]} ms</div>
|
|
75
|
+
</div>
|
|
76
|
+
<div class="card">
|
|
77
|
+
<div class="card-title">Total Cost</div>
|
|
78
|
+
<div class="card-value">$#{sprintf('%.4f', summary[:total_cost])}</div>
|
|
79
|
+
</div>
|
|
80
|
+
<div class="card">
|
|
81
|
+
<div class="card-title">Recommendation</div>
|
|
82
|
+
<div class="card-value" style="font-size:1.2rem; margin-top:0.8rem;">#{summary[:recommendation]}</div>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<section>
|
|
87
|
+
<h2>Summary & Analysis</h2>
|
|
88
|
+
<p><strong>Average Judge Score:</strong> #{summary[:average_judge_score] || 'N/A'}</p>
|
|
89
|
+
<p><strong>Variance:</strong> #{summary[:variance]}</p>
|
|
90
|
+
</section>
|
|
91
|
+
|
|
92
|
+
<section>
|
|
93
|
+
<h2>Test Runs & Prompt Details</h2>
|
|
94
|
+
#{render_runs_table}
|
|
95
|
+
</section>
|
|
96
|
+
</div>
|
|
97
|
+
</body>
|
|
98
|
+
</html>
|
|
99
|
+
HTML
|
|
100
|
+
|
|
101
|
+
output_io.puts html
|
|
102
|
+
html
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def render_runs_table
|
|
108
|
+
rows = results[:runs].map.with_index do |run, idx|
|
|
109
|
+
assertions_html = run[:assertion_results].map do |ar|
|
|
110
|
+
cls = ar.passed? ? "badge-pass" : "badge-fail"
|
|
111
|
+
st = ar.passed? ? "PASS" : "FAIL"
|
|
112
|
+
"<div><strong class='#{cls}'>[#{st}] #{ar.name}</strong>: #{ar.message}</div>"
|
|
113
|
+
end.join
|
|
114
|
+
|
|
115
|
+
<<~TR
|
|
116
|
+
<tr>
|
|
117
|
+
<td>#{idx + 1}</td>
|
|
118
|
+
<td><pre>#{run[:input]}</pre></td>
|
|
119
|
+
<td><pre>#{run[:output]}</pre></td>
|
|
120
|
+
<td>#{run[:latency_ms].round(1)} ms</td>
|
|
121
|
+
<td>#{assertions_html}</td>
|
|
122
|
+
</tr>
|
|
123
|
+
TR
|
|
124
|
+
end.join
|
|
125
|
+
|
|
126
|
+
<<~TABLE
|
|
127
|
+
<table>
|
|
128
|
+
<thead>
|
|
129
|
+
<tr>
|
|
130
|
+
<th>#</th>
|
|
131
|
+
<th>Input / Prompt</th>
|
|
132
|
+
<th>AI Output</th>
|
|
133
|
+
<th>Latency</th>
|
|
134
|
+
<th>Assertion Results</th>
|
|
135
|
+
</tr>
|
|
136
|
+
</thead>
|
|
137
|
+
<tbody>
|
|
138
|
+
#{rows}
|
|
139
|
+
</tbody>
|
|
140
|
+
</table>
|
|
141
|
+
TABLE
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
AISpec::Core::Reporters::Base.register("html", AISpec::Core::Reporters::HTML)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module AISpec
|
|
6
|
+
module Core
|
|
7
|
+
module Reporters
|
|
8
|
+
class JSONReporter < Base
|
|
9
|
+
def render
|
|
10
|
+
contract = results[:contract]
|
|
11
|
+
stats = results[:statistics]
|
|
12
|
+
|
|
13
|
+
data = {
|
|
14
|
+
contract: contract.name,
|
|
15
|
+
provider: results[:provider_name],
|
|
16
|
+
model: results[:model],
|
|
17
|
+
summary: stats.summary,
|
|
18
|
+
runs: results[:runs].map do |run|
|
|
19
|
+
{
|
|
20
|
+
input: run[:input],
|
|
21
|
+
output: run[:output],
|
|
22
|
+
latency_ms: run[:latency_ms],
|
|
23
|
+
cost: run[:cost],
|
|
24
|
+
tokens: run[:tokens],
|
|
25
|
+
passed: run[:passed],
|
|
26
|
+
assertions: run[:assertion_results].map do |ar|
|
|
27
|
+
{
|
|
28
|
+
name: ar.name,
|
|
29
|
+
passed: ar.passed?,
|
|
30
|
+
message: ar.message,
|
|
31
|
+
metadata: ar.metadata
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
formatted = JSON.pretty_generate(data)
|
|
39
|
+
output_io.puts formatted
|
|
40
|
+
formatted
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
AISpec::Core::Reporters::Base.register("json", AISpec::Core::Reporters::JSONReporter)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require "rexml/document"
|
|
5
|
+
rescue LoadError
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
module AISpec
|
|
9
|
+
module Core
|
|
10
|
+
module Reporters
|
|
11
|
+
class JUnit < Base
|
|
12
|
+
def render
|
|
13
|
+
contract = results[:contract]
|
|
14
|
+
stats = results[:statistics]
|
|
15
|
+
summary = stats.summary
|
|
16
|
+
|
|
17
|
+
doc = REXML::Document.new
|
|
18
|
+
doc << REXML::XMLDecl.new("1.0", "UTF-8")
|
|
19
|
+
|
|
20
|
+
testsuite = doc.add_element("testsuite", {
|
|
21
|
+
"name" => contract.name,
|
|
22
|
+
"tests" => summary[:total_assertions].to_s,
|
|
23
|
+
"failures" => summary[:failed_assertions].to_s,
|
|
24
|
+
"time" => (summary[:mean_latency] / 1000.0).to_s
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
results[:runs].each_with_index do |run, i|
|
|
28
|
+
run[:assertion_results].each do |ar|
|
|
29
|
+
tc = testsuite.add_element("testcase", {
|
|
30
|
+
"classname" => "#{contract.name}.Case#{i + 1}",
|
|
31
|
+
"name" => ar.name.to_s,
|
|
32
|
+
"time" => (run[:latency_ms] / 1000.0).to_s
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
unless ar.passed?
|
|
36
|
+
failure = tc.add_element("failure", { "message" => ar.message })
|
|
37
|
+
failure.text = ar.message
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
output = ""
|
|
43
|
+
doc.write(output, 2)
|
|
44
|
+
output_io.puts output
|
|
45
|
+
output
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
AISpec::Core::Reporters::Base.register("junit", AISpec::Core::Reporters::JUnit)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AISpec
|
|
4
|
+
module Core
|
|
5
|
+
module Reporters
|
|
6
|
+
class Markdown < Base
|
|
7
|
+
def render
|
|
8
|
+
contract = results[:contract]
|
|
9
|
+
stats = results[:statistics]
|
|
10
|
+
summary = stats.summary
|
|
11
|
+
|
|
12
|
+
md = []
|
|
13
|
+
md << "## AISpec Contract Verification Report: `#{contract.name}`"
|
|
14
|
+
md << ""
|
|
15
|
+
md << "| Metric | Value |"
|
|
16
|
+
md << "| ------ | ----- |"
|
|
17
|
+
md << "| **Provider / Model** | `#{results[:provider_name]}` / `#{results[:model]}` |"
|
|
18
|
+
md << "| **Behavior Score** | **#{summary[:success_rate]}%** (Confidence ±#{summary[:confidence_interval]}%) |"
|
|
19
|
+
md << "| **Assertions Passed** | #{summary[:passed_assertions]} / #{summary[:total_assertions]} |"
|
|
20
|
+
md << "| **Mean Latency** | #{summary[:mean_latency]}ms |"
|
|
21
|
+
md << "| **Total Cost** | $#{sprintf('%.4f', summary[:total_cost])} |"
|
|
22
|
+
md << "| **Judge Score** | #{summary[:average_judge_score] || 'N/A'} |"
|
|
23
|
+
md << "| **Variance** | #{summary[:variance]} |"
|
|
24
|
+
md << "| **Recommendation** | **#{summary[:recommendation]}** |"
|
|
25
|
+
md << ""
|
|
26
|
+
md << "### Assertion Breakdown"
|
|
27
|
+
md << ""
|
|
28
|
+
|
|
29
|
+
results[:runs].each_with_index do |run, i|
|
|
30
|
+
md << "#### Case ##{i + 1}: `#{run[:input]}`"
|
|
31
|
+
run[:assertion_results].each do |ar|
|
|
32
|
+
badge = ar.passed? ? "✅ PASS" : "❌ FAIL"
|
|
33
|
+
md << "- #{badge} **#{ar.name}**: #{ar.message}"
|
|
34
|
+
end
|
|
35
|
+
md << ""
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
text = md.join("\n")
|
|
39
|
+
output_io.puts text
|
|
40
|
+
text
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
AISpec::Core::Reporters::Base.register("markdown", AISpec::Core::Reporters::Markdown)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AISpec
|
|
4
|
+
module Core
|
|
5
|
+
class Runner
|
|
6
|
+
attr_reader :contract, :provider_override
|
|
7
|
+
|
|
8
|
+
def initialize(contract, provider_override: nil)
|
|
9
|
+
@contract = contract.is_a?(Contract) ? contract : Contract.load_file(contract.to_s)
|
|
10
|
+
@provider_override = provider_override
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def run(dataset_item_filter: nil)
|
|
14
|
+
provider_name = provider_override || contract.provider_name
|
|
15
|
+
provider_class = Providers::Base.get(provider_name)
|
|
16
|
+
provider_instance = provider_class.new(model: contract.model)
|
|
17
|
+
|
|
18
|
+
items = contract.dataset_items
|
|
19
|
+
items = [ { input: "Default test input" } ] if items.empty?
|
|
20
|
+
|
|
21
|
+
run_results = items.map do |item|
|
|
22
|
+
execute_item(item, provider_instance)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
stats = Statistics::Calculator.new(run_results)
|
|
26
|
+
|
|
27
|
+
{
|
|
28
|
+
contract: contract,
|
|
29
|
+
provider_name: provider_name,
|
|
30
|
+
model: contract.model,
|
|
31
|
+
runs: run_results,
|
|
32
|
+
statistics: stats
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def execute_item(item, provider)
|
|
39
|
+
input_text = item[:input] || item[:prompt] || item.to_s
|
|
40
|
+
rendered_prompt = render_prompt(contract.prompt_template, item)
|
|
41
|
+
|
|
42
|
+
context = {
|
|
43
|
+
input: input_text,
|
|
44
|
+
system_prompt: contract.raw_hash[:system_prompt],
|
|
45
|
+
item: item
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
response = provider.generate(rendered_prompt, system_prompt: context[:system_prompt])
|
|
49
|
+
|
|
50
|
+
assertion_results = contract.assertions.map do |assertion_def|
|
|
51
|
+
type = assertion_def[:type]
|
|
52
|
+
options = assertion_def[:options] || {}
|
|
53
|
+
Assertion.evaluate(type, response, context, options)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
{
|
|
57
|
+
item: item,
|
|
58
|
+
input: input_text,
|
|
59
|
+
prompt: rendered_prompt,
|
|
60
|
+
response: response,
|
|
61
|
+
output: response[:output],
|
|
62
|
+
latency_ms: response[:latency_ms],
|
|
63
|
+
cost: response[:cost],
|
|
64
|
+
tokens: response[:tokens],
|
|
65
|
+
assertion_results: assertion_results,
|
|
66
|
+
passed: assertion_results.all?(&:passed?)
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def render_prompt(template, item)
|
|
71
|
+
result = template.dup
|
|
72
|
+
if item.is_a?(Hash)
|
|
73
|
+
item.each do |k, v|
|
|
74
|
+
result.gsub!("{{#{k}}}", v.to_s)
|
|
75
|
+
result.gsub!("{{ #{k} }}", v.to_s)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
result.gsub!("{{input}}", (item[:input] || item[:prompt] || "").to_s)
|
|
79
|
+
result
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|