14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
|
# File 'lib/aispec/core/assertions/deterministic.rb', line 14
def self.register_all!
Assertion.register(:contains) do |response, _context, options|
expected = options[:value] || options[:target] || options[:raw]
text = response[:output].to_s
match = text.include?(expected.to_s)
[match, match ? "Output contains '#{expected}'" : "Output does not contain '#{expected}'"]
end
Assertion.register(:not_contains) do |response, _context, options|
expected = options[:value] || options[:target]
text = response[:output].to_s
match = !text.include?(expected.to_s)
[match, match ? "Output does not contain '#{expected}'" : "Output contains forbidden text '#{expected}'"]
end
Assertion.register(:starts_with) do |response, _context, options|
prefix = options[:value] || options[:target]
text = response[:output].to_s.strip
match = text.start_with?(prefix.to_s)
[match, match ? "Output starts with '#{prefix}'" : "Output does not start with '#{prefix}'"]
end
Assertion.register(:ends_with) do |response, _context, options|
suffix = options[:value] || options[:target]
text = response[:output].to_s.strip
match = text.end_with?(suffix.to_s)
[match, match ? "Output ends with '#{suffix}'" : "Output does not end with '#{suffix}'"]
end
Assertion.register(:regex) do |response, _context, options|
pattern = options[:pattern] || options[:value] || options[:regex]
regex = pattern.is_a?(Regexp) ? pattern : Regexp.new(pattern.to_s)
text = response[:output].to_s
match = !!(text =~ regex)
[match, match ? "Output matches regex /#{pattern}/" : "Output failed to match regex /#{pattern}/"]
end
Assertion.register(:json) do |response, _context, options|
text = response[:output].to_s.strip
cleaned = text.sub(/^```json\s*/i, "").sub(/^```\s*/, "").sub(/\s*```$/, "").strip
cleaned = $1 if cleaned =~ /(\{[\s\S]*\}|\[[\s\S]*\])/
begin
parsed = JSON.parse(cleaned)
if options[:schema]
if options[:schema].is_a?(Array)
missing_keys = options[:schema].map(&:to_s) - parsed.keys.map(&:to_s)
[missing_keys.empty?, missing_keys.empty? ? "JSON satisfies schema keys" : "JSON missing schema keys: #{missing_keys.join(', ')}"]
else
[true, "Valid JSON"]
end
else
[true, "Output is valid JSON"]
end
rescue JSON::ParserError => e
[false, "Output is not valid JSON: #{e.message}"]
end
end
Assertion.register(:valid_markdown) do |response, _context, _options|
text = response[:output].to_s
fence_count = text.scan(/^```/).length
valid = fence_count.even?
[valid, valid ? "Valid Markdown structural formatting" : "Invalid Markdown (unmatched code block fences)"]
end
Assertion.register(:valid_xml) do |response, _context, _options|
text = response[:output].to_s.strip
if defined?(REXML::Document)
begin
REXML::Document.new(text)
[true, "Output is valid XML"]
rescue REXML::ParseException => e
[false, "Invalid XML: #{e.message}"]
end
else
valid = text =~ /\A\s*<[a-zA-Z_][\s\S]*>\s*\z/
[!!valid, valid ? "Output is valid XML structure" : "Invalid XML structure"]
end
end
Assertion.register(:max_latency) do |response, _context, options|
limit = (options[:value] || options[:max] || options[:limit] || 5000).to_f
actual = (response[:latency_ms] || 0.0).to_f
passed = actual <= limit
[passed, passed ? "Latency #{actual.round(1)}ms <= #{limit.round(1)}ms" : "Latency #{actual.round(1)}ms exceeded max #{limit.round(1)}ms"]
end
Assertion.register(:cost) do |response, _context, options|
max_cost = (options[:max] || options[:value] || 0.05).to_f
actual_cost = (response[:cost] || 0.0).to_f
passed = actual_cost <= max_cost
[passed, passed ? "Cost $#{sprintf('%.4f', actual_cost)} <= $#{sprintf('%.4f', max_cost)}" : "Cost $#{sprintf('%.4f', actual_cost)} exceeded max $#{sprintf('%.4f', max_cost)}"]
end
Assertion.register(:must_not_reveal_system_prompt) do |response, context, _options|
sys_prompt = context[:system_prompt].to_s.strip
text = response[:output].to_s
if sys_prompt.empty?
[true, "No system prompt leak detected"]
else
match = text.include?(sys_prompt)
[!match, match ? "System prompt leaked in output" : "No system prompt leak detected"]
end
end
Assertion.register(:tool_called) do |response, _context, options|
expected = (options[:value] || options[:name] || options[:tool]).to_s
tools = Array(response[:tools_called]).map(&:to_s)
called = tools.include?(expected)
[called, called ? "Tool '#{expected}' was called" : "Tool '#{expected}' was not called"]
end
end
|