activeagent 1.3.1 → 1.5.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 +421 -0
- data/lib/active_agent/evals/design_tokens.rb +130 -0
- data/lib/active_agent/evals/diagnosis.rb +258 -0
- data/lib/active_agent/evals/judge.rb +212 -0
- data/lib/active_agent/evals/model_spec.rb +80 -0
- data/lib/active_agent/evals/publisher.rb +76 -0
- data/lib/active_agent/evals/replay.rb +63 -0
- data/lib/active_agent/evals/report.rb +450 -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 +204 -0
- data/lib/active_agent/evals/scenario.rb +68 -0
- data/lib/active_agent/evals/scenario_parser.rb +265 -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 +61 -0
- data/lib/active_agent/providers/_base_provider.rb +5 -1
- data/lib/active_agent/providers/mock/messages/base.rb +4 -2
- data/lib/active_agent/providers/open_ai/responses/transforms.rb +42 -11
- 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 +29 -6
- data/lib/active_agent/version.rb +1 -1
- data/lib/active_agent.rb +1 -0
- metadata +19 -3
|
@@ -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,204 @@
|
|
|
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 both
|
|
12
|
+
# its mean score and the mean of its judge grades (task completion, or the
|
|
13
|
+
# configured llm_judge criteria) reached `threshold`;
|
|
14
|
+
# anything else carries exactly one fault
|
|
15
|
+
# and a recommendation from Diagnosis, refined by the `judge` for the
|
|
16
|
+
# faults in `refine_faults` (up to `judge_limit` calls per run).
|
|
17
|
+
#
|
|
18
|
+
# Runner.new(
|
|
19
|
+
# scenarios: suite.scenarios(groups: %w[blame]),
|
|
20
|
+
# models: ModelSpec.parse_all(%w[gpt-5-mini claude-haiku-4-5], default_provider: "openai"),
|
|
21
|
+
# criteria: [{ "key" => "response_present", "type" => "response_present" }],
|
|
22
|
+
# available_tools: { "find_records" => "Look up records" },
|
|
23
|
+
# instructions: agent.instructions,
|
|
24
|
+
# judge: Judge.new(label: "claude-opus-5") { |instructions:, prompt:| ... },
|
|
25
|
+
# replay: ->(scenario, spec) { ... },
|
|
26
|
+
# on_result: ->(result) { persist(result) }
|
|
27
|
+
# ).call
|
|
28
|
+
class Runner
|
|
29
|
+
# Faults where a judge can add something the evidence alone cannot: what
|
|
30
|
+
# tool to add, or how to change the instructions.
|
|
31
|
+
DEFAULT_REFINE_FAULTS = %w[missing_capability expected_tool_not_called low_quality missing_content].freeze
|
|
32
|
+
DEFAULT_JUDGE_LIMIT = 25
|
|
33
|
+
|
|
34
|
+
attr_reader :scenarios, :models, :criteria, :judge, :threshold
|
|
35
|
+
|
|
36
|
+
# @param scenarios [Array<Scenario>]
|
|
37
|
+
# @param models [Array<ModelSpec>]
|
|
38
|
+
# @param replay [#call] `(scenario, model_spec) → Replay`
|
|
39
|
+
# @param criteria [Array<Hash>] Scorer criteria applied to every replay
|
|
40
|
+
# @param judge [Judge, nil]
|
|
41
|
+
# @param judge_task [Boolean] with a judge and no llm_judge criterion, also
|
|
42
|
+
# score task completion (`Judge#score_task`) as the `task_completion` criterion
|
|
43
|
+
# @param available_tools [Hash{String=>String}, Array<String>] the agent's tool roster
|
|
44
|
+
# @param instructions [String, nil] the agent's instructions, for the judge
|
|
45
|
+
# @param agent_name [String] how recommendations refer to the agent
|
|
46
|
+
# @param on_result [#call, nil] called with each Result as it lands
|
|
47
|
+
# @param around_evaluation [#call, nil] called with (scenario, spec) and a
|
|
48
|
+
# block that returns the Result. Establishes context for replay, scoring
|
|
49
|
+
# and recommendations; must return the block's result. Wrapper errors
|
|
50
|
+
# propagate to the caller. Applies to #call, not direct #evaluate calls.
|
|
51
|
+
# @param require_judge_scores [Boolean] fail an otherwise passing result
|
|
52
|
+
# when a requested task/LLM grade is unavailable, rather than falling
|
|
53
|
+
# back to rule scores. Does not require a judge for rules-only runs.
|
|
54
|
+
def initialize(scenarios:, models:, replay:, criteria: [], judge: nil, judge_task: true, available_tools: {},
|
|
55
|
+
instructions: nil, agent_name: "The agent", threshold: PASS_THRESHOLD,
|
|
56
|
+
refine_faults: DEFAULT_REFINE_FAULTS, judge_limit: DEFAULT_JUDGE_LIMIT, on_result: nil,
|
|
57
|
+
around_evaluation: nil, require_judge_scores: false, metadata: {})
|
|
58
|
+
@scenarios = scenarios
|
|
59
|
+
@models = models
|
|
60
|
+
@replay = replay
|
|
61
|
+
@criteria = criteria
|
|
62
|
+
@judge = judge
|
|
63
|
+
@judge_task = judge_task
|
|
64
|
+
@available_tools = normalize_tools(available_tools)
|
|
65
|
+
@instructions = instructions
|
|
66
|
+
@agent_name = agent_name
|
|
67
|
+
@threshold = threshold
|
|
68
|
+
@refine_faults = refine_faults
|
|
69
|
+
@judge_limit = judge_limit
|
|
70
|
+
@on_result = on_result
|
|
71
|
+
@around_evaluation = around_evaluation
|
|
72
|
+
@require_judge_scores = require_judge_scores
|
|
73
|
+
@metadata = metadata
|
|
74
|
+
@judge_calls = 0
|
|
75
|
+
@scorer = Scorer.new(criteria: criteria, judge: judge)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def call
|
|
79
|
+
results = @scenarios.flat_map do |scenario|
|
|
80
|
+
@models.map do |spec|
|
|
81
|
+
evaluate_with_context(scenario, spec).tap { |result| @on_result&.call(result) }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
Report.new(results: results, models: @models, judge: @judge, instructions: @instructions,
|
|
86
|
+
threshold: @threshold, metadata: @metadata)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Scores and diagnoses one replay. Public so a caller that has already run
|
|
90
|
+
# the agent (a background job per scenario, say) can score without the loop.
|
|
91
|
+
def evaluate(scenario, spec, replay = nil)
|
|
92
|
+
replay ||= run_replay(scenario, spec)
|
|
93
|
+
scores = @scorer.score(scenario, replay)
|
|
94
|
+
if judge_task? && replay.answer.present?
|
|
95
|
+
scores["task_completion"] = @judge.score_task(scenario: scenario, answer: replay.answer)
|
|
96
|
+
end
|
|
97
|
+
score = Scorer.mean(scores)
|
|
98
|
+
|
|
99
|
+
diagnosis = Diagnosis.call(scenario: scenario, replay: replay, scores: scores, score: score,
|
|
100
|
+
available_tools: @available_tools.keys, threshold: @threshold, agent_name: @agent_name,
|
|
101
|
+
judge_keys: llm_judge_keys)
|
|
102
|
+
diagnosis ||= unavailable_judge_diagnosis(scores)
|
|
103
|
+
diagnosis_hash = diagnosis&.to_h
|
|
104
|
+
refine!(diagnosis_hash, scenario, replay, diagnosis) if diagnosis_hash
|
|
105
|
+
|
|
106
|
+
Result.new(
|
|
107
|
+
scenario: scenario,
|
|
108
|
+
spec: spec,
|
|
109
|
+
replay: replay,
|
|
110
|
+
scores: scores,
|
|
111
|
+
score: score,
|
|
112
|
+
status: replay.errored? ? "errored" : (diagnosis ? "failed" : "passed"),
|
|
113
|
+
diagnosis: diagnosis_hash
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def evaluate_with_context(scenario, spec)
|
|
120
|
+
return evaluate(scenario, spec) unless @around_evaluation
|
|
121
|
+
|
|
122
|
+
result = @around_evaluation.call(scenario, spec) { evaluate(scenario, spec) }
|
|
123
|
+
# A wrapper written the natural way — do something, yield, do something
|
|
124
|
+
# after — returns that last value rather than the Result. Left alone it
|
|
125
|
+
# reaches on_result and the Report, and fails somewhere far from the
|
|
126
|
+
# wrapper that caused it. Name the wrapper here instead.
|
|
127
|
+
unless result.is_a?(Result)
|
|
128
|
+
raise ArgumentError, "around_evaluation must return the Result its block yields, got #{result.class}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
result
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def judge_task?
|
|
135
|
+
@judge && @judge_task && @criteria.none? { |criterion| criterion.to_h.stringify_keys["type"] == "llm_judge" }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The keys in `scores` a judge graded, so a low grade is not averaged
|
|
139
|
+
# away against rule checks.
|
|
140
|
+
def llm_judge_keys
|
|
141
|
+
@llm_judge_keys ||= @criteria.filter_map do |criterion|
|
|
142
|
+
value = criterion.to_h.stringify_keys
|
|
143
|
+
value["key"] if value["type"] == "llm_judge"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def unavailable_judge_diagnosis(scores)
|
|
148
|
+
return unless @require_judge_scores
|
|
149
|
+
|
|
150
|
+
keys = llm_judge_keys.dup
|
|
151
|
+
keys << "task_completion" if judge_task?
|
|
152
|
+
missing = keys.select { |key| scores[key].nil? }
|
|
153
|
+
return if missing.empty?
|
|
154
|
+
|
|
155
|
+
Diagnosis::Result.new(
|
|
156
|
+
fault: "judge_unavailable",
|
|
157
|
+
summary: "The evaluation judge did not return a usable score for #{missing.join(', ')}.",
|
|
158
|
+
recommendation: "Check the judge's credentials, model availability and JSON response, then re-run this evaluation. " \
|
|
159
|
+
"The available rule scores do not establish answer quality.",
|
|
160
|
+
evidence: { "unscored_criteria" => missing }
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Whatever the callable raises becomes an errored Replay, so one model
|
|
165
|
+
# rejecting a parameter fails its scenario rather than the whole run.
|
|
166
|
+
# A return value that is neither a Replay nor a Hash is the caller's
|
|
167
|
+
# bug and raises.
|
|
168
|
+
def run_replay(scenario, spec)
|
|
169
|
+
value =
|
|
170
|
+
begin
|
|
171
|
+
@replay.call(scenario, spec)
|
|
172
|
+
rescue StandardError => e
|
|
173
|
+
return Replay.failed(e)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
case value
|
|
177
|
+
when Replay then value
|
|
178
|
+
when Hash then Replay.new(**value.to_h.symbolize_keys)
|
|
179
|
+
else raise ArgumentError, "replay must return an ActiveAgent::Evals::Replay or a Hash, got #{value.class}"
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def refine!(diagnosis, scenario, replay, result)
|
|
184
|
+
return unless @judge && @refine_faults.include?(result.fault)
|
|
185
|
+
return if @judge_calls >= @judge_limit
|
|
186
|
+
|
|
187
|
+
@judge_calls += 1
|
|
188
|
+
refined = @judge.recommend(scenario: scenario, replay: replay, diagnosis: result,
|
|
189
|
+
available_tools: @available_tools, instructions: @instructions)
|
|
190
|
+
return unless refined
|
|
191
|
+
|
|
192
|
+
diagnosis["judge"] = refined
|
|
193
|
+
diagnosis["recommendation"] = refined["recommendation"].to_s.strip if refined["recommendation"].present?
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def normalize_tools(tools)
|
|
197
|
+
case tools
|
|
198
|
+
when Hash then tools.to_h { |name, description| [ name.to_s, description.to_s ] }
|
|
199
|
+
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, "" ] }
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
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,265 @@
|
|
|
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
|
+
# - a grouped Suite document in YAML or JSON, retaining its expectations,
|
|
19
|
+
# group names, stable keys, notes and production-only flags
|
|
20
|
+
#
|
|
21
|
+
# Every scenario gets a key unique within the paste, derived from its group
|
|
22
|
+
# and position ("blame_3"), unless the line names one. The result is an
|
|
23
|
+
# array of string-keyed hashes; `Scenario.from_hash` builds the structs.
|
|
24
|
+
class ScenarioParser
|
|
25
|
+
class ParseError < ArgumentError; end
|
|
26
|
+
|
|
27
|
+
LIST_MARKER = /\A\s*(?:[-*•]|\d+[.)])\s+/
|
|
28
|
+
HEADING = /\A\s*#+\s+(.+?)\s*\z/
|
|
29
|
+
BOLD_HEADING = /\A\s*\*\*(.+?)\*\*:?\s*(?:—.*)?\z/
|
|
30
|
+
# Only a backticked span that opens the line is the prompt; a message
|
|
31
|
+
# that merely mentions `some_tool` is kept whole.
|
|
32
|
+
BACKTICK_PROMPT = /\A`([^`]+)`/
|
|
33
|
+
OPTION_KEYS = %w[tools contains not_contains key group notes].freeze
|
|
34
|
+
|
|
35
|
+
def self.parse(text, include_production_only: true)
|
|
36
|
+
new(text).parse(include_production_only: include_production_only)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Parses and builds Scenario structs in one step.
|
|
40
|
+
def self.scenarios(text, include_production_only: true)
|
|
41
|
+
parse(text, include_production_only: include_production_only).map { |attrs| Scenario.from_hash(attrs) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def initialize(text)
|
|
45
|
+
@text = text.to_s
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @return [Array<Hash>] scenario attributes with string keys
|
|
49
|
+
def parse(include_production_only: true)
|
|
50
|
+
stripped = @text.strip
|
|
51
|
+
return [] if stripped.empty?
|
|
52
|
+
|
|
53
|
+
scenarios = if json?(stripped)
|
|
54
|
+
parse_json(stripped)
|
|
55
|
+
elsif stripped.match?(/^(?:suite|groups):(?:\s|$)/)
|
|
56
|
+
parse_suite_yaml(stripped)
|
|
57
|
+
else
|
|
58
|
+
parse_lines(stripped)
|
|
59
|
+
end
|
|
60
|
+
assigned = assign_keys(scenarios)
|
|
61
|
+
include_production_only ? assigned : assigned.reject { |entry| entry["production_only"] }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def json?(text)
|
|
67
|
+
text.start_with?("[", "{")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def parse_json(text)
|
|
71
|
+
parsed = JSON.parse(text)
|
|
72
|
+
return parse_suite(parsed) if parsed.is_a?(Hash) && parsed.key?("groups")
|
|
73
|
+
|
|
74
|
+
parsed = parsed["scenarios"] if parsed.is_a?(Hash) && parsed.key?("scenarios")
|
|
75
|
+
parsed = [ parsed ] if parsed.is_a?(Hash)
|
|
76
|
+
|
|
77
|
+
Array(parsed).filter_map do |entry|
|
|
78
|
+
case entry
|
|
79
|
+
when String then scenario(prompt: entry)
|
|
80
|
+
when Hash then scenario_from_hash(entry)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
rescue JSON::ParserError
|
|
84
|
+
parse_lines(text)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def parse_suite_yaml(text)
|
|
88
|
+
document = YAML.safe_load(text, aliases: true)
|
|
89
|
+
raise ParseError, "evaluation suite must contain a groups array" unless document.is_a?(Hash) && document["groups"].is_a?(Array)
|
|
90
|
+
|
|
91
|
+
parse_suite(document)
|
|
92
|
+
rescue Psych::Exception => e
|
|
93
|
+
raise ParseError, "invalid evaluation suite YAML: #{e.message}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def parse_suite(document)
|
|
97
|
+
raise ParseError, "evaluation suite must contain a groups array" unless document["groups"].is_a?(Array)
|
|
98
|
+
|
|
99
|
+
document["groups"].each do |group|
|
|
100
|
+
unless group.is_a?(Hash) && (group["scenarios"].nil? || group["scenarios"].is_a?(Array))
|
|
101
|
+
raise ParseError, "each evaluation group must contain a scenarios array"
|
|
102
|
+
end
|
|
103
|
+
Array(group["scenarios"]).each do |entry|
|
|
104
|
+
unless entry.is_a?(Hash) && entry["prompt"].is_a?(String) && entry["prompt"].present?
|
|
105
|
+
raise ParseError, "each evaluation scenario must contain a prompt"
|
|
106
|
+
end
|
|
107
|
+
expectations = entry["expectations"] || entry["expect"]
|
|
108
|
+
if expectations && !expectations.is_a?(Hash)
|
|
109
|
+
raise ParseError, "scenario expectations must be an object"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
Suite.new([ document ]).all_scenarios.map do |item|
|
|
115
|
+
scenario(prompt: item.prompt, group: item.group, group_name: item.group_name, key: item.key,
|
|
116
|
+
notes: item.notes, expectations: item.expectations, production_only: item.production_only?)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def scenario_from_hash(entry)
|
|
121
|
+
entry = entry.stringify_keys
|
|
122
|
+
prompt = entry["prompt"] || entry["message"] || entry["input"] || entry["question"]
|
|
123
|
+
return nil if prompt.blank?
|
|
124
|
+
|
|
125
|
+
expectations = (entry["expectations"] || entry["expect"] || {}).to_h.stringify_keys
|
|
126
|
+
%w[tools contains not_contains].each do |field|
|
|
127
|
+
expectations[field] = Array(entry[field]) if entry.key?(field)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
scenario(
|
|
131
|
+
prompt: prompt,
|
|
132
|
+
group: entry["group"],
|
|
133
|
+
group_name: entry["group_name"],
|
|
134
|
+
key: entry["key"],
|
|
135
|
+
notes: entry["notes"],
|
|
136
|
+
expectations: expectations,
|
|
137
|
+
production_only: entry["production_only"] == true
|
|
138
|
+
)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def parse_lines(text)
|
|
142
|
+
group = nil
|
|
143
|
+
scenarios = []
|
|
144
|
+
|
|
145
|
+
text.each_line do |raw|
|
|
146
|
+
line = raw.strip
|
|
147
|
+
next if line.empty?
|
|
148
|
+
|
|
149
|
+
if (heading = heading_for(line))
|
|
150
|
+
group = heading
|
|
151
|
+
next
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
scenarios << parse_line(line, group)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
scenarios
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def heading_for(line)
|
|
161
|
+
return Regexp.last_match(1).strip if line =~ HEADING
|
|
162
|
+
return strip_markup(Regexp.last_match(1)) if line =~ BOLD_HEADING
|
|
163
|
+
return line.chomp(":").strip if colon_heading?(line)
|
|
164
|
+
|
|
165
|
+
nil
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# `Find records:` reads as a heading; a question, or a line carrying
|
|
169
|
+
# `| options`, does not, however it ends.
|
|
170
|
+
def colon_heading?(line)
|
|
171
|
+
line.end_with?(":") && line.length <= 80 && !line.match?(LIST_MARKER) &&
|
|
172
|
+
!line.include?("?") && !line.include?(" | ")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def parse_line(line, group)
|
|
176
|
+
body = line.sub(LIST_MARKER, "")
|
|
177
|
+
body, options = split_options(body)
|
|
178
|
+
|
|
179
|
+
if (match = body.match(BACKTICK_PROMPT))
|
|
180
|
+
prompt = match[1].strip
|
|
181
|
+
notes = body.sub(match[0], "").sub(/\A\s*[—–-]\s*/, "").strip.presence
|
|
182
|
+
else
|
|
183
|
+
prompt = strip_markup(body)
|
|
184
|
+
notes = nil
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
scenario(
|
|
188
|
+
prompt: prompt,
|
|
189
|
+
group: options["group"].presence || group,
|
|
190
|
+
key: options["key"],
|
|
191
|
+
notes: [ notes, options["notes"] ].compact.join(" ").presence,
|
|
192
|
+
expectations: options.slice("tools", "contains", "not_contains").transform_values { |value| split_list(value) }
|
|
193
|
+
)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# `prompt | tools: a, b | contains: x` → [prompt, { "tools" => "a, b", ... }]
|
|
197
|
+
def split_options(body)
|
|
198
|
+
segments = body.split(/\s+\|\s+/)
|
|
199
|
+
return [ body, {} ] if segments.size == 1
|
|
200
|
+
|
|
201
|
+
options = {}
|
|
202
|
+
rest = [ segments.shift ]
|
|
203
|
+
segments.each do |segment|
|
|
204
|
+
key, value = segment.split(":", 2)
|
|
205
|
+
if value && OPTION_KEYS.include?(key.strip.downcase)
|
|
206
|
+
options[key.strip.downcase] = value.strip
|
|
207
|
+
else
|
|
208
|
+
rest << segment
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
[ rest.join(" | "), options ]
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def split_list(value)
|
|
216
|
+
value.to_s.split(/\s*[,;]\s*/).map(&:strip).reject(&:blank?)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def strip_markup(text)
|
|
220
|
+
text.to_s.gsub(/\*\*|__|`/, "").strip
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def scenario(prompt:, group: nil, group_name: nil, key: nil, notes: nil, expectations: {}, production_only: false)
|
|
224
|
+
{
|
|
225
|
+
"prompt" => prompt.to_s.strip,
|
|
226
|
+
"group" => group.presence&.to_s&.strip,
|
|
227
|
+
"group_name" => group_name.presence&.to_s&.strip,
|
|
228
|
+
"key" => key.presence&.to_s&.strip,
|
|
229
|
+
"notes" => notes.presence,
|
|
230
|
+
"expectations" => (expectations || {}).reject { |_, value| value.blank? },
|
|
231
|
+
"production_only" => production_only
|
|
232
|
+
}
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# A key named on a line is kept; a generated one never collides with a
|
|
236
|
+
# named key anywhere in the paste; and a named key that repeats an
|
|
237
|
+
# earlier line's is treated as missing, so no two scenarios share one.
|
|
238
|
+
def assign_keys(scenarios)
|
|
239
|
+
named = scenarios.filter_map { |s| s["key"].presence }.to_set
|
|
240
|
+
taken = Set.new
|
|
241
|
+
counters = Hash.new(0)
|
|
242
|
+
|
|
243
|
+
scenarios.each_with_index do |scenario, index|
|
|
244
|
+
scenario["position"] = index
|
|
245
|
+
key = scenario["key"].presence
|
|
246
|
+
key = nil if key && taken.include?(key)
|
|
247
|
+
|
|
248
|
+
unless key
|
|
249
|
+
base = scenario["group"].to_s.parameterize(separator: "_").first(30).presence || "scenario"
|
|
250
|
+
loop do
|
|
251
|
+
counters[base] += 1
|
|
252
|
+
key = "#{base}_#{counters[base]}"
|
|
253
|
+
break unless named.include?(key) || taken.include?(key)
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
taken << key
|
|
258
|
+
scenario["key"] = key
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
scenarios
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|