activeagent 1.3.0 → 1.4.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.
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Evals
5
+ # One scenario replayed under one model: the Replay, its scores, and the
6
+ # diagnosis when it fell short.
7
+ #
8
+ # `status` is "errored" when the run raised, "failed" when a fault was
9
+ # assigned, and "passed" otherwise. `diagnosis` is the Diagnosis::Result
10
+ # hash, with a `"judge"` sub-hash when a Judge refined it.
11
+ Result = Struct.new(:scenario, :spec, :replay, :scores, :score, :status, :diagnosis, keyword_init: true) do
12
+ def passed?
13
+ status == "passed"
14
+ end
15
+
16
+ def failed?
17
+ status == "failed"
18
+ end
19
+
20
+ def errored?
21
+ status == "errored"
22
+ end
23
+
24
+ def label
25
+ spec.label
26
+ end
27
+
28
+ def model
29
+ spec.model
30
+ end
31
+
32
+ def provider
33
+ spec.provider
34
+ end
35
+
36
+ def fault
37
+ diagnosis && diagnosis["fault"]
38
+ end
39
+
40
+ def summary
41
+ diagnosis && diagnosis["summary"]
42
+ end
43
+
44
+ def recommendation
45
+ diagnosis && diagnosis["recommendation"]
46
+ end
47
+
48
+ def suggested_tool
49
+ diagnosis&.dig("judge", "suggested_tool")
50
+ end
51
+
52
+ def to_h
53
+ {
54
+ "scenario_key" => scenario.key,
55
+ "group" => scenario.group,
56
+ "prompt" => scenario.prompt,
57
+ "label" => label,
58
+ "provider" => provider,
59
+ "model" => model,
60
+ "status" => status,
61
+ "score" => score,
62
+ "scores" => scores,
63
+ "answer" => replay.answer,
64
+ "tool_calls" => replay.tool_calls,
65
+ "duration_ms" => replay.duration_ms,
66
+ "input_tokens" => replay.input_tokens,
67
+ "output_tokens" => replay.output_tokens,
68
+ "cost" => replay.cost,
69
+ "error" => replay.error,
70
+ "fault" => fault,
71
+ "recommendation" => recommendation,
72
+ "diagnosis" => diagnosis,
73
+ "metadata" => replay.metadata
74
+ }.compact
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Evals
5
+ # Replays every scenario under every candidate model, scores and diagnoses
6
+ # each answer, and returns a Report.
7
+ #
8
+ # The one thing the runner does not know is how to talk to your agent;
9
+ # `replay` is a callable `(scenario, model_spec) → Replay` (a Hash with the
10
+ # same keys is accepted, and an exception becomes an errored Replay). A
11
+ # scenario passes when its replay completed, met its expectations, and its
12
+ # mean score reached `threshold`; anything else carries exactly one fault
13
+ # and a recommendation from Diagnosis, refined by the `judge` for the
14
+ # faults in `refine_faults` (up to `judge_limit` calls per run).
15
+ #
16
+ # Runner.new(
17
+ # scenarios: suite.scenarios(groups: %w[blame]),
18
+ # models: ModelSpec.parse_all(%w[gpt-5-mini claude-haiku-4-5], default_provider: "openai"),
19
+ # criteria: [{ "key" => "response_present", "type" => "response_present" }],
20
+ # available_tools: { "find_records" => "Look up records" },
21
+ # instructions: agent.instructions,
22
+ # judge: Judge.new(label: "claude-opus-5") { |instructions:, prompt:| ... },
23
+ # replay: ->(scenario, spec) { ... },
24
+ # on_result: ->(result) { persist(result) }
25
+ # ).call
26
+ class Runner
27
+ # Faults where a judge can add something the evidence alone cannot: what
28
+ # tool to add, or how to change the instructions.
29
+ DEFAULT_REFINE_FAULTS = %w[missing_capability expected_tool_not_called low_quality missing_content].freeze
30
+ DEFAULT_JUDGE_LIMIT = 25
31
+
32
+ attr_reader :scenarios, :models, :criteria, :judge, :threshold
33
+
34
+ # @param scenarios [Array<Scenario>]
35
+ # @param models [Array<ModelSpec>]
36
+ # @param replay [#call] `(scenario, model_spec) → Replay`
37
+ # @param criteria [Array<Hash>] Scorer criteria applied to every replay
38
+ # @param judge [Judge, nil]
39
+ # @param judge_task [Boolean] with a judge and no llm_judge criterion, also
40
+ # score task completion (`Judge#score_task`) as the `task_completion` criterion
41
+ # @param available_tools [Hash{String=>String}, Array<String>] the agent's tool roster
42
+ # @param instructions [String, nil] the agent's instructions, for the judge
43
+ # @param agent_name [String] how recommendations refer to the agent
44
+ # @param on_result [#call, nil] called with each Result as it lands
45
+ def initialize(scenarios:, models:, replay:, criteria: [], judge: nil, judge_task: true, available_tools: {},
46
+ instructions: nil, agent_name: "The agent", threshold: PASS_THRESHOLD,
47
+ refine_faults: DEFAULT_REFINE_FAULTS, judge_limit: DEFAULT_JUDGE_LIMIT, on_result: nil, metadata: {})
48
+ @scenarios = scenarios
49
+ @models = models
50
+ @replay = replay
51
+ @criteria = criteria
52
+ @judge = judge
53
+ @judge_task = judge_task
54
+ @available_tools = normalize_tools(available_tools)
55
+ @instructions = instructions
56
+ @agent_name = agent_name
57
+ @threshold = threshold
58
+ @refine_faults = refine_faults
59
+ @judge_limit = judge_limit
60
+ @on_result = on_result
61
+ @metadata = metadata
62
+ @judge_calls = 0
63
+ @scorer = Scorer.new(criteria: criteria, judge: judge)
64
+ end
65
+
66
+ def call
67
+ results = @scenarios.flat_map do |scenario|
68
+ @models.map do |spec|
69
+ evaluate(scenario, spec).tap { |result| @on_result&.call(result) }
70
+ end
71
+ end
72
+
73
+ Report.new(results: results, models: @models, judge: @judge, instructions: @instructions,
74
+ threshold: @threshold, metadata: @metadata)
75
+ end
76
+
77
+ # Scores and diagnoses one replay. Public so a caller that has already run
78
+ # the agent (a background job per scenario, say) can score without the loop.
79
+ def evaluate(scenario, spec, replay = nil)
80
+ replay ||= run_replay(scenario, spec)
81
+ scores = @scorer.score(scenario, replay)
82
+ if judge_task? && replay.answer.present?
83
+ scores["task_completion"] = @judge.score_task(scenario: scenario, answer: replay.answer)
84
+ end
85
+ score = Scorer.mean(scores)
86
+
87
+ diagnosis = Diagnosis.call(scenario: scenario, replay: replay, scores: scores, score: score,
88
+ available_tools: @available_tools.keys, threshold: @threshold, agent_name: @agent_name)
89
+ diagnosis_hash = diagnosis&.to_h
90
+ refine!(diagnosis_hash, scenario, replay, diagnosis) if diagnosis_hash
91
+
92
+ Result.new(
93
+ scenario: scenario,
94
+ spec: spec,
95
+ replay: replay,
96
+ scores: scores,
97
+ score: score,
98
+ status: replay.errored? ? "errored" : (diagnosis ? "failed" : "passed"),
99
+ diagnosis: diagnosis_hash
100
+ )
101
+ end
102
+
103
+ private
104
+
105
+ def judge_task?
106
+ @judge && @judge_task && @criteria.none? { |criterion| criterion.to_h.stringify_keys["type"] == "llm_judge" }
107
+ end
108
+
109
+ # Whatever the callable raises becomes an errored Replay, so one model
110
+ # rejecting a parameter fails its scenario rather than the whole run.
111
+ # A return value that is neither a Replay nor a Hash is the caller's
112
+ # bug and raises.
113
+ def run_replay(scenario, spec)
114
+ value =
115
+ begin
116
+ @replay.call(scenario, spec)
117
+ rescue StandardError => e
118
+ return Replay.failed(e)
119
+ end
120
+
121
+ case value
122
+ when Replay then value
123
+ when Hash then Replay.new(**value.to_h.symbolize_keys)
124
+ else raise ArgumentError, "replay must return an ActiveAgent::Evals::Replay or a Hash, got #{value.class}"
125
+ end
126
+ end
127
+
128
+ def refine!(diagnosis, scenario, replay, result)
129
+ return unless @judge && @refine_faults.include?(result.fault)
130
+ return if @judge_calls >= @judge_limit
131
+
132
+ @judge_calls += 1
133
+ refined = @judge.recommend(scenario: scenario, replay: replay, diagnosis: result,
134
+ available_tools: @available_tools, instructions: @instructions)
135
+ return unless refined
136
+
137
+ diagnosis["judge"] = refined
138
+ diagnosis["recommendation"] = refined["recommendation"].to_s.strip if refined["recommendation"].present?
139
+ end
140
+
141
+ def normalize_tools(tools)
142
+ case tools
143
+ when Hash then tools.to_h { |name, description| [ name.to_s, description.to_s ] }
144
+ else Array(tools).to_h { |tool| tool.respond_to?(:name) ? [ tool.name.to_s, (tool.respond_to?(:description) ? tool.description.to_s : "") ] : [ tool.to_s, "" ] }
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Evals
5
+ # One task an evaluation replays through the agent: the message a user
6
+ # would send, the group of related tasks it belongs to, and what a passing
7
+ # answer is expected to do.
8
+ #
9
+ # @!attribute key
10
+ # @return [String] stable within a suite ("blame_3"), so results line up across runs
11
+ # @!attribute group
12
+ # @return [String, nil] the group key ("blame")
13
+ # @!attribute group_name
14
+ # @return [String, nil] the group's display name ("Blame / audit")
15
+ # @!attribute expected_tools
16
+ # @return [Array<String>] tool names a passing answer calls (any one of them)
17
+ # @!attribute expected_patterns
18
+ # @return [Array<String>] substrings or patterns the answer must contain
19
+ # @!attribute forbidden_patterns
20
+ # @return [Array<String>] substrings or patterns the answer must avoid
21
+ # @!attribute production_only
22
+ # @return [Boolean] whether a local environment cannot answer with real data
23
+ Scenario = Struct.new(:key, :group, :group_name, :prompt, :expected_tools, :expected_patterns,
24
+ :forbidden_patterns, :notes, :production_only, :position, keyword_init: true) do
25
+ # Builds a scenario from a hash — ScenarioParser output, a suite entry, or
26
+ # a persisted record's attributes. Expectations are read from an
27
+ # `expectations`/`expect` sub-hash (`tools`, `contains`, `not_contains`)
28
+ # or from those keys at the top level.
29
+ def self.from_hash(attributes, group: nil, group_name: nil)
30
+ attrs = attributes.to_h.deep_stringify_keys
31
+ expect = (attrs["expectations"] || attrs["expect"] || {}).to_h.stringify_keys
32
+
33
+ new(
34
+ key: attrs["key"].to_s,
35
+ group: attrs["group"].presence || group,
36
+ group_name: attrs["group_name"].presence || group_name,
37
+ prompt: attrs.fetch("prompt").to_s.strip,
38
+ expected_tools: list(expect["tools"] || attrs["tools"]),
39
+ expected_patterns: list(expect["contains"] || attrs["contains"]),
40
+ forbidden_patterns: list(expect["not_contains"] || attrs["not_contains"]),
41
+ notes: attrs["notes"].presence,
42
+ production_only: attrs["production_only"] == true,
43
+ position: attrs["position"]
44
+ )
45
+ end
46
+
47
+ def self.list(value)
48
+ Array(value).map(&:to_s).reject(&:blank?)
49
+ end
50
+
51
+ def production_only?
52
+ production_only == true
53
+ end
54
+
55
+ def expectations
56
+ {
57
+ "tools" => expected_tools,
58
+ "contains" => expected_patterns,
59
+ "not_contains" => forbidden_patterns
60
+ }.reject { |_, value| value.blank? }
61
+ end
62
+
63
+ def to_h
64
+ super.compact
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Evals
5
+ # Turns a pasted list of user messages into scenario attributes. Accepts the
6
+ # shapes people actually paste:
7
+ #
8
+ # - one message per line, with or without list markers (`-`, `*`, `1.`)
9
+ # - `# Heading` or `**Heading**` lines, which start a group; so does a
10
+ # short unmarked line ending in a colon (`Find records:`)
11
+ # - a message in backticks at the start of the line, followed by notes,
12
+ # as in an issue's question catalog:
13
+ # `` 3. `Show me all providers with no license on file` — 1,060 locally ``
14
+ # - trailing ` | tools: a, b | contains: x | not_contains: y | key: k`
15
+ # options on a line
16
+ # - a JSON array of strings, or of objects with `prompt` (or `message`),
17
+ # `group`, `key`, `notes`, `tools`, `contains`, `not_contains`
18
+ #
19
+ # Every scenario gets a key unique within the paste, derived from its group
20
+ # and position ("blame_3"), unless the line names one. The result is an
21
+ # array of string-keyed hashes; `Scenario.from_hash` builds the structs.
22
+ class ScenarioParser
23
+ LIST_MARKER = /\A\s*(?:[-*•]|\d+[.)])\s+/
24
+ HEADING = /\A\s*#+\s+(.+?)\s*\z/
25
+ BOLD_HEADING = /\A\s*\*\*(.+?)\*\*:?\s*(?:—.*)?\z/
26
+ # Only a backticked span that opens the line is the prompt; a message
27
+ # that merely mentions `some_tool` is kept whole.
28
+ BACKTICK_PROMPT = /\A`([^`]+)`/
29
+ OPTION_KEYS = %w[tools contains not_contains key group notes].freeze
30
+
31
+ def self.parse(text)
32
+ new(text).parse
33
+ end
34
+
35
+ # Parses and builds Scenario structs in one step.
36
+ def self.scenarios(text)
37
+ parse(text).map { |attrs| Scenario.from_hash(attrs) }
38
+ end
39
+
40
+ def initialize(text)
41
+ @text = text.to_s
42
+ end
43
+
44
+ # @return [Array<Hash>] scenario attributes with string keys
45
+ def parse
46
+ stripped = @text.strip
47
+ return [] if stripped.empty?
48
+
49
+ scenarios = json?(stripped) ? parse_json(stripped) : parse_lines(stripped)
50
+ assign_keys(scenarios)
51
+ end
52
+
53
+ private
54
+
55
+ def json?(text)
56
+ text.start_with?("[", "{")
57
+ end
58
+
59
+ def parse_json(text)
60
+ parsed = JSON.parse(text)
61
+ parsed = parsed["scenarios"] if parsed.is_a?(Hash) && parsed.key?("scenarios")
62
+ parsed = [ parsed ] if parsed.is_a?(Hash)
63
+
64
+ Array(parsed).filter_map do |entry|
65
+ case entry
66
+ when String then scenario(prompt: entry)
67
+ when Hash then scenario_from_hash(entry)
68
+ end
69
+ end
70
+ rescue JSON::ParserError
71
+ parse_lines(text)
72
+ end
73
+
74
+ def scenario_from_hash(entry)
75
+ entry = entry.stringify_keys
76
+ prompt = entry["prompt"] || entry["message"] || entry["input"] || entry["question"]
77
+ return nil if prompt.blank?
78
+
79
+ expectations = (entry["expectations"] || entry["expect"] || {}).to_h.stringify_keys
80
+ %w[tools contains not_contains].each do |field|
81
+ expectations[field] = Array(entry[field]) if entry.key?(field)
82
+ end
83
+
84
+ scenario(
85
+ prompt: prompt,
86
+ group: entry["group"],
87
+ key: entry["key"],
88
+ notes: entry["notes"],
89
+ expectations: expectations
90
+ )
91
+ end
92
+
93
+ def parse_lines(text)
94
+ group = nil
95
+ scenarios = []
96
+
97
+ text.each_line do |raw|
98
+ line = raw.strip
99
+ next if line.empty?
100
+
101
+ if (heading = heading_for(line))
102
+ group = heading
103
+ next
104
+ end
105
+
106
+ scenarios << parse_line(line, group)
107
+ end
108
+
109
+ scenarios
110
+ end
111
+
112
+ def heading_for(line)
113
+ return Regexp.last_match(1).strip if line =~ HEADING
114
+ return strip_markup(Regexp.last_match(1)) if line =~ BOLD_HEADING
115
+ return line.chomp(":").strip if colon_heading?(line)
116
+
117
+ nil
118
+ end
119
+
120
+ # `Find records:` reads as a heading; a question, or a line carrying
121
+ # `| options`, does not, however it ends.
122
+ def colon_heading?(line)
123
+ line.end_with?(":") && line.length <= 80 && !line.match?(LIST_MARKER) &&
124
+ !line.include?("?") && !line.include?(" | ")
125
+ end
126
+
127
+ def parse_line(line, group)
128
+ body = line.sub(LIST_MARKER, "")
129
+ body, options = split_options(body)
130
+
131
+ if (match = body.match(BACKTICK_PROMPT))
132
+ prompt = match[1].strip
133
+ notes = body.sub(match[0], "").sub(/\A\s*[—–-]\s*/, "").strip.presence
134
+ else
135
+ prompt = strip_markup(body)
136
+ notes = nil
137
+ end
138
+
139
+ scenario(
140
+ prompt: prompt,
141
+ group: options["group"].presence || group,
142
+ key: options["key"],
143
+ notes: [ notes, options["notes"] ].compact.join(" ").presence,
144
+ expectations: options.slice("tools", "contains", "not_contains").transform_values { |value| split_list(value) }
145
+ )
146
+ end
147
+
148
+ # `prompt | tools: a, b | contains: x` → [prompt, { "tools" => "a, b", ... }]
149
+ def split_options(body)
150
+ segments = body.split(/\s+\|\s+/)
151
+ return [ body, {} ] if segments.size == 1
152
+
153
+ options = {}
154
+ rest = [ segments.shift ]
155
+ segments.each do |segment|
156
+ key, value = segment.split(":", 2)
157
+ if value && OPTION_KEYS.include?(key.strip.downcase)
158
+ options[key.strip.downcase] = value.strip
159
+ else
160
+ rest << segment
161
+ end
162
+ end
163
+
164
+ [ rest.join(" | "), options ]
165
+ end
166
+
167
+ def split_list(value)
168
+ value.to_s.split(/\s*[,;]\s*/).map(&:strip).reject(&:blank?)
169
+ end
170
+
171
+ def strip_markup(text)
172
+ text.to_s.gsub(/\*\*|__|`/, "").strip
173
+ end
174
+
175
+ def scenario(prompt:, group: nil, key: nil, notes: nil, expectations: {})
176
+ {
177
+ "prompt" => prompt.to_s.strip,
178
+ "group" => group.presence&.to_s&.strip,
179
+ "key" => key.presence&.to_s&.strip,
180
+ "notes" => notes.presence,
181
+ "expectations" => (expectations || {}).reject { |_, value| value.blank? }
182
+ }
183
+ end
184
+
185
+ # A key named on a line is kept; a generated one never collides with a
186
+ # named key anywhere in the paste; and a named key that repeats an
187
+ # earlier line's is treated as missing, so no two scenarios share one.
188
+ def assign_keys(scenarios)
189
+ named = scenarios.filter_map { |s| s["key"].presence }.to_set
190
+ taken = Set.new
191
+ counters = Hash.new(0)
192
+
193
+ scenarios.each_with_index do |scenario, index|
194
+ scenario["position"] = index
195
+ key = scenario["key"].presence
196
+ key = nil if key && taken.include?(key)
197
+
198
+ unless key
199
+ base = scenario["group"].to_s.parameterize(separator: "_").first(30).presence || "scenario"
200
+ loop do
201
+ counters[base] += 1
202
+ key = "#{base}_#{counters[base]}"
203
+ break unless named.include?(key) || taken.include?(key)
204
+ end
205
+ end
206
+
207
+ taken << key
208
+ scenario["key"] = key
209
+ end
210
+
211
+ scenarios
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Evals
5
+ # Scores one Replay against an evaluation's criteria and the scenario's
6
+ # expectations. Returns `criterion key => 0.0..1.0`, with nil for a
7
+ # criterion that could not be scored (an llm_judge criterion with no judge).
8
+ #
9
+ # Criteria are `{ "key", "type", "config" }` hashes:
10
+ #
11
+ # response_present — the answer is non-empty
12
+ # min_length — `config.chars` characters (partial credit below)
13
+ # max_latency_ms — `config.ms` budget (partial credit above)
14
+ # token_budget — `config.output_tokens` budget (partial credit above)
15
+ # contains — `config.pattern` (a substring, or a regex) is present
16
+ # not_contains — `config.pattern` is absent
17
+ # llm_judge — the judge scores the answer against `config.prompt`
18
+ #
19
+ # The scenario's own expectations add `expected_tools`, `expected_content`,
20
+ # `forbidden_content` (when declared) and `tools_succeeded` (when any tool
21
+ # was called).
22
+ class Scorer
23
+ RULE_CRITERION_TYPES = %w[response_present min_length max_latency_ms token_budget contains not_contains].freeze
24
+ CRITERION_TYPES = (RULE_CRITERION_TYPES + %w[llm_judge]).freeze
25
+
26
+ attr_reader :criteria, :judge
27
+
28
+ def initialize(criteria: [], judge: nil)
29
+ @criteria = Array(criteria).map { |criterion| criterion.to_h.deep_stringify_keys }
30
+ @judge = judge
31
+ end
32
+
33
+ def score(scenario, replay)
34
+ scores = {}
35
+ answer = replay.answer.to_s
36
+
37
+ @criteria.each do |criterion|
38
+ scores[criterion["key"]] = answer.present? ? score_criterion(criterion, scenario, replay) : 0.0
39
+ end
40
+
41
+ if scenario.expected_tools.any?
42
+ scores["expected_tools"] = (scenario.expected_tools & replay.tool_names).any? ? 1.0 : 0.0
43
+ end
44
+ if scenario.expected_patterns.any?
45
+ hits = scenario.expected_patterns.count { |pattern| self.class.matches_pattern?(answer, pattern) }
46
+ scores["expected_content"] = (hits.to_f / scenario.expected_patterns.size).round(3)
47
+ end
48
+ if scenario.forbidden_patterns.any?
49
+ hit = scenario.forbidden_patterns.any? { |pattern| self.class.matches_pattern?(answer, pattern) }
50
+ scores["forbidden_content"] = hit ? 0.0 : 1.0
51
+ end
52
+ if replay.tool_calls.any?
53
+ scores["tools_succeeded"] = replay.failed_tool_calls.any? ? 0.0 : 1.0
54
+ end
55
+
56
+ scores
57
+ end
58
+
59
+ # The mean of the scored criteria, or nil when nothing could be scored.
60
+ def self.mean(scores)
61
+ scored = scores.values.compact
62
+ return nil if scored.empty?
63
+
64
+ (scored.sum / scored.size).round(3)
65
+ end
66
+
67
+ # How long one pattern may take to match one answer. Patterns are
68
+ # whatever the scenario's author typed, and a pathological one must not
69
+ # stall the evaluation.
70
+ PATTERN_TIMEOUT = 1.0
71
+
72
+ # Whether `pattern` occurs in `text`: as a plain substring, case
73
+ # insensitively, or else as a regex. A pattern that is not a valid
74
+ # regex, or that takes longer than PATTERN_TIMEOUT, only counts as a
75
+ # substring.
76
+ def self.matches_pattern?(text, pattern)
77
+ pattern = pattern.to_s
78
+ return false if pattern.blank?
79
+
80
+ text = text.to_s
81
+ return true if text.downcase.include?(pattern.downcase)
82
+
83
+ text.match?(Regexp.new(pattern, Regexp::IGNORECASE, timeout: PATTERN_TIMEOUT))
84
+ rescue RegexpError
85
+ false
86
+ end
87
+
88
+ private
89
+
90
+ def score_criterion(criterion, scenario, replay)
91
+ config = criterion["config"] || {}
92
+ answer = replay.answer.to_s
93
+
94
+ case criterion["type"]
95
+ when "response_present"
96
+ answer.present? ? 1.0 : 0.0
97
+ when "min_length"
98
+ min = config.fetch("chars", 40).to_i
99
+ [ answer.length.to_f / [ min, 1 ].max, 1.0 ].min
100
+ when "max_latency_ms"
101
+ budget = config.fetch("ms", 5_000).to_f
102
+ duration = replay.duration_ms.to_f
103
+ duration.zero? || duration <= budget ? 1.0 : [ budget / duration, 1.0 ].min
104
+ when "token_budget"
105
+ budget = config.fetch("output_tokens", 1_000).to_f
106
+ tokens = replay.output_tokens.to_f
107
+ tokens <= budget ? 1.0 : [ budget / tokens, 1.0 ].min
108
+ when "contains"
109
+ self.class.matches_pattern?(answer, config["pattern"]) ? 1.0 : 0.0
110
+ when "not_contains"
111
+ self.class.matches_pattern?(answer, config["pattern"]) ? 0.0 : 1.0
112
+ when "llm_judge"
113
+ @judge&.score_criterion(criterion: criterion, prompt: scenario.prompt, answer: answer)
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end