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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +158 -0
- data/lib/active_agent/evals/design_tokens.rb +130 -0
- data/lib/active_agent/evals/diagnosis.rb +238 -0
- data/lib/active_agent/evals/judge.rb +205 -0
- data/lib/active_agent/evals/model_spec.rb +80 -0
- data/lib/active_agent/evals/replay.rb +63 -0
- data/lib/active_agent/evals/report.rb +447 -0
- data/lib/active_agent/evals/report_html.rb +634 -0
- data/lib/active_agent/evals/result.rb +78 -0
- data/lib/active_agent/evals/runner.rb +149 -0
- data/lib/active_agent/evals/scenario.rb +68 -0
- data/lib/active_agent/evals/scenario_parser.rb +215 -0
- data/lib/active_agent/evals/scorer.rb +118 -0
- data/lib/active_agent/evals/suite.rb +99 -0
- data/lib/active_agent/evals.rb +60 -0
- data/lib/active_agent/providers/ruby_llm/options.rb +4 -0
- data/lib/active_agent/providers/ruby_llm_provider.rb +14 -1
- data/lib/active_agent/providers/rubyllm_provider.rb +1 -0
- data/lib/active_agent/telemetry/configuration.rb +11 -0
- data/lib/active_agent/telemetry/instrumentation.rb +26 -6
- data/lib/active_agent/version.rb +1 -1
- data/lib/active_agent.rb +1 -0
- metadata +22 -4
|
@@ -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
|