activeagent 1.9.6 → 1.9.7

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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +436 -28
  3. data/README.md +6 -0
  4. data/lib/active_agent/base.rb +1 -0
  5. data/lib/active_agent/cli/eval_commands.rb +3 -3
  6. data/lib/active_agent/concerns/tooling.rb +9 -0
  7. data/lib/active_agent/evals/capture.rb +275 -0
  8. data/lib/active_agent/evals/catalog.rb +150 -19
  9. data/lib/active_agent/evals/gate_check.rb +79 -0
  10. data/lib/active_agent/evals/pass_thresholds.rb +137 -0
  11. data/lib/active_agent/evals/patterns.rb +80 -0
  12. data/lib/active_agent/evals/publisher.rb +124 -8
  13. data/lib/active_agent/evals/report.rb +165 -20
  14. data/lib/active_agent/evals/report_html.rb +91 -17
  15. data/lib/active_agent/evals/report_markdown.rb +63 -6
  16. data/lib/active_agent/evals/result.rb +10 -2
  17. data/lib/active_agent/evals/rspec.rb +87 -0
  18. data/lib/active_agent/evals/ruby_llm.rb +105 -3
  19. data/lib/active_agent/evals/runner.rb +59 -16
  20. data/lib/active_agent/evals/scenario.rb +12 -4
  21. data/lib/active_agent/evals/scenario_parser.rb +9 -1
  22. data/lib/active_agent/evals/scorer.rb +53 -23
  23. data/lib/active_agent/evals/suite.rb +117 -6
  24. data/lib/active_agent/evals/test_helper.rb +286 -0
  25. data/lib/active_agent/evals.rb +9 -1
  26. data/lib/active_agent/providers/_base_provider.rb +17 -1
  27. data/lib/active_agent/providers/atlas_cloud/options.rb +35 -0
  28. data/lib/active_agent/providers/atlas_cloud_provider.rb +45 -0
  29. data/lib/active_agent/providers/cheaper_inference/_types.rb +17 -0
  30. data/lib/active_agent/providers/cheaper_inference/options.rb +39 -0
  31. data/lib/active_agent/providers/cheaper_inference_provider.rb +72 -0
  32. data/lib/active_agent/providers/ollama/_types.rb +1 -0
  33. data/lib/active_agent/providers/ollama/catalog.rb +221 -0
  34. data/lib/active_agent/providers/ollama/cloud.rb +109 -0
  35. data/lib/active_agent/providers/ollama/options.rb +66 -2
  36. data/lib/active_agent/providers/ollama_provider.rb +55 -3
  37. data/lib/active_agent/railtie.rb +4 -0
  38. data/lib/active_agent/version.rb +1 -1
  39. data/lib/tasks/active_agent/evals.rake +28 -0
  40. metadata +16 -3
  41. data/lib/tasks/activeagent_tasks.rake +0 -4
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Evals
5
+ # The bars a suite declares for a run: the score every answer must reach
6
+ # (the Runner's and the Report's threshold), the share of a model's
7
+ # answers that must pass, and the groups whose results are reported but
8
+ # not gated. Report#gates compares each group's pass rate against them.
9
+ #
10
+ # suite: support_desk
11
+ # pass:
12
+ # threshold: 0.7
13
+ # min_pass_rate: 0.8
14
+ # groups:
15
+ # - key: orders
16
+ # pass: { min_pass_rate: 1.0 }
17
+ # - key: experimental
18
+ # pass: { gated: false }
19
+ #
20
+ # A group's `min_pass_rate` falls back to the suite's, and the suite's to
21
+ # DEFAULT_MIN_PASS_RATE; a group is gated unless it says `gated: false`.
22
+ # Layered documents override key by key at both levels, so a document
23
+ # that sets one group's bar leaves the suite's threshold and the other
24
+ # groups alone.
25
+ class PassThresholds
26
+ DEFAULT_MIN_PASS_RATE = 1.0
27
+ SUITE_KEYS = %w[threshold min_pass_rate].freeze
28
+ GROUP_KEYS = %w[min_pass_rate gated].freeze
29
+
30
+ # Layers each document's top-level `pass` and each group's `pass`
31
+ # (groups under `groups`, keyed by their `key`), later documents
32
+ # winning key by key. A group without a key has no scenarios to gate,
33
+ # so a `pass` block on one is refused.
34
+ #
35
+ # @param documents [Array<Hash>] parsed YAML documents, base first
36
+ def self.from_documents(documents)
37
+ suite = {}
38
+ groups = {}
39
+ Array(documents).each do |document|
40
+ document = document.to_h.deep_stringify_keys
41
+ suite.merge!(pass_block(document["pass"], "the suite"))
42
+ Array(document["groups"]).each do |group|
43
+ group = group.to_h.deep_stringify_keys
44
+ key = group["key"].to_s
45
+ raise ArgumentError, "\"pass\" of a group without a key gates nothing" if key.empty? && !group["pass"].nil?
46
+
47
+ groups[key] = groups.fetch(key, {}).merge(pass_block(group["pass"], "group #{key}"))
48
+ end
49
+ end
50
+ new(suite: suite, groups: groups)
51
+ end
52
+
53
+ def self.default
54
+ new
55
+ end
56
+
57
+ # A `pass` block as a string-keyed Hash; nothing declared reads as {},
58
+ # and anything but a mapping raises ArgumentError naming `place`.
59
+ def self.pass_block(value, place)
60
+ return {} if value.nil?
61
+ raise ArgumentError, "\"pass\" of #{place} must be a mapping, got #{value.inspect}" unless value.is_a?(Hash)
62
+
63
+ value.deep_stringify_keys
64
+ end
65
+
66
+ # `pass` as a string-keyed Hash of the `allowed` keys (SUITE_KEYS or
67
+ # GROUP_KEYS), each refusal naming `place` — the suite, a group, or
68
+ # whatever a caller holds a block for: a bar is a number from 0 to 1,
69
+ # kept as a Float so `1` and `1.0` read, serialize and hash the same,
70
+ # and `gated` is true or false.
71
+ def self.validate(pass, allowed, place)
72
+ pass_block(pass, place).to_h do |key, value|
73
+ raise ArgumentError, "unknown key #{key.inspect} in \"pass\" of #{place}; expected one of #{allowed.join(', ')}" unless allowed.include?(key)
74
+
75
+ if key == "gated"
76
+ raise ArgumentError, "\"gated\" in \"pass\" of #{place} must be true or false, got #{value.inspect}" unless [ true, false ].include?(value)
77
+
78
+ [ key, value ]
79
+ elsif value.is_a?(Numeric) && value.finite? && value.between?(0, 1)
80
+ [ key, value.to_f ]
81
+ else
82
+ raise ArgumentError, "#{key.inspect} in \"pass\" of #{place} must be a number from 0 to 1, got #{value.inspect}"
83
+ end
84
+ end
85
+ end
86
+
87
+ # @param suite [Hash] the suite's bars, `threshold` and `min_pass_rate`
88
+ # @param groups [Hash{String=>Hash}] each group's bars by group key,
89
+ # `min_pass_rate` and `gated`
90
+ def initialize(suite: {}, groups: {})
91
+ @suite = self.class.validate(suite, SUITE_KEYS, "the suite")
92
+ @groups = groups.to_h.deep_stringify_keys.filter_map do |key, pass|
93
+ declared = self.class.validate(pass, GROUP_KEYS, "group #{key}")
94
+ [ key.to_s, declared ] if declared.any?
95
+ end.to_h
96
+ end
97
+
98
+ # The score every answer must reach, 0..1.
99
+ def threshold
100
+ (@suite["threshold"] || PASS_THRESHOLD).to_f
101
+ end
102
+
103
+ # The share of a model's answers that must pass, 0..1: the group's own
104
+ # bar when it declared one, else the suite's, else DEFAULT_MIN_PASS_RATE.
105
+ def min_pass_rate(group = nil)
106
+ own = @groups.dig(group.to_s, "min_pass_rate") unless group.nil?
107
+ (own || @suite["min_pass_rate"] || DEFAULT_MIN_PASS_RATE).to_f
108
+ end
109
+
110
+ # False only for a group that declared `gated: false`; nil and unknown
111
+ # groups are gated.
112
+ def gated?(group)
113
+ return true if group.nil?
114
+
115
+ @groups.dig(group.to_s, "gated") != false
116
+ end
117
+
118
+ # The bars in effect, with each group's declared keys only.
119
+ def to_h
120
+ {
121
+ "threshold" => threshold,
122
+ "min_pass_rate" => min_pass_rate,
123
+ "groups" => @groups.transform_values(&:dup)
124
+ }
125
+ end
126
+
127
+ def ==(other)
128
+ other.is_a?(PassThresholds) && to_h == other.to_h
129
+ end
130
+ alias eql? ==
131
+
132
+ def hash
133
+ to_h.hash
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Evals
5
+ # Regexes for a figure an answer is expected to state, built from the data
6
+ # the scenario was seeded with: a count ("3 of 4 tickets") or a share of
7
+ # a total ("75%"). Each is a String that drops into a suite's
8
+ # `expect.contains` / `not_contains` or a `contains` criterion, which
9
+ # Scorer.matches_pattern? tries as a substring and then as a regex:
10
+ #
11
+ # "expect" => { "contains" => [ Patterns.number(3), Patterns.share(3, 4) ] }
12
+ module Patterns
13
+ MONTHS = %w[january february march april may june july august september october november december].freeze
14
+
15
+ # `text` as a pattern that matches it in any case, each letter a
16
+ # character class: any_case("Oct.") is "[Oo][Cc][Tt]\\.".
17
+ def self.any_case(text)
18
+ text.chars.map { |char| char.match?(/[a-z]/i) ? "[#{char.upcase}#{char.downcase}]" : Regexp.escape(char) }.join
19
+ end
20
+ private_class_method :any_case
21
+
22
+ # A day of the month written beside its month, in any case: "Oct 12",
23
+ # "Sept. 12" and "October 12" before the figure, "12 Oct" and "12th of
24
+ # October" after it. A lookbehind takes alternatives of one length only,
25
+ # so the spellings before the figure are one lookbehind per length, and
26
+ # each letter in it is a character class rather than `(?i:...)`: the
27
+ # dashes make the pattern UTF-8, where case folding lets "st" match the
28
+ # one-character ligature "st", and Ruby 3.2 refuses a lookbehind whose
29
+ # length that leaves open. "may" is left out after the figure, where it
30
+ # is usually the verb.
31
+ MONTH_BEFORE = MONTHS.flat_map { |month| [ month, month[0, 3], "#{month[0, 3]}." ] }.push("sept", "sept.").uniq
32
+ .group_by(&:length).sort.map { |_length, spellings| "(?<!#{spellings.map { |spelling| "\\b#{any_case("#{spelling} ")}" }.join('|')})" }
33
+ .join.freeze
34
+ MONTH_AFTER = (MONTHS - %w[may]).map { |month| "#{month[0, 3]}(?:#{Regexp.escape(month[3..])})?" }.push("sept").join("|").freeze
35
+
36
+ module_function
37
+
38
+ # The integer `count` as a figure on its own: number(10) matches
39
+ # "10 sessions", "(10)" and "10." at a sentence end, not "2026-10-01",
40
+ # "10/1", "10:00", "1-10" or "1–10", "110", "10.5", "10,000", "10%",
41
+ # "Oct 10" or "10 October". A ratio such as "10/22" does not match
42
+ # either, because "10/12" reads as a date as often as a count; expect
43
+ # "10 of 22". `count` is read as a whole number from its text, so 3 and
44
+ # "3" work and 1.5, 10.0, "a" or a negative count raise ArgumentError.
45
+ def number(count)
46
+ count = whole_number(count, "count")
47
+ "(?<![\\d/:.,\\-\u2013\u2014])#{MONTH_BEFORE}\\b#{count}\\b(?![/:\\-\u2013\u2014])(?![.,]\\d)(?! ?%)" \
48
+ "(?!(?i:(?:st|nd|rd|th)?,? (?:of )?(?:#{MONTH_AFTER})\\b))"
49
+ end
50
+
51
+ # `count` of `total` as a percentage, whole or to one or two decimals,
52
+ # with an optional space before the sign: share(3, 4) matches "75%",
53
+ # "75 %", "75.0%" and "75.00%", not "175%", "7.5%", "75.5%" or "0.75".
54
+ # The whole percent rounds half up like Format.whole and the decimals
55
+ # like Float#round, so share(1, 8) matches "13%", "12.5%" and "12.50%",
56
+ # and share(12, 22) matches "55%", "54.5%" and "54.55%". Both arguments are read like
57
+ # `number`'s; a total below one and a count above the total raise
58
+ # ArgumentError.
59
+ def share(count, total)
60
+ count = whole_number(count, "count")
61
+ total = whole_number(total, "total", minimum: 1)
62
+ raise ArgumentError, "count must not exceed total" if count > total
63
+
64
+ percent = count * 100.0 / total
65
+ decimals = [ 2, 1 ].map { |places| Regexp.escape(format("%.#{places}f", percent.round(places))) }
66
+ "(?<![\\d.,])\\b(?:#{decimals.join('|')}|#{Format.whole(percent)}) ?%"
67
+ end
68
+
69
+ # `value` as an Integer of at least `minimum`, read from its text so
70
+ # that 1.5 is rejected rather than truncated: 3 and "3" → 3; 1.5, "a"
71
+ # and a value below `minimum` raise ArgumentError.
72
+ def whole_number(value, name, minimum: 0)
73
+ number = Integer(value.to_s, 10)
74
+ raise ArgumentError, "#{name} must be #{minimum.positive? ? 'positive' : 'zero or positive'}" if number < minimum
75
+
76
+ number
77
+ end
78
+ end
79
+ end
80
+ end
@@ -20,12 +20,21 @@ module ActiveAgent
20
20
  # ArgumentError before anything is sent.
21
21
  class Publisher
22
22
  DEFAULT_ENDPOINT = "https://api.activeagents.ai/v1/evaluations"
23
+ # The hosted dashboard people sign in to; the hosted collector answers
24
+ # on the API host, where a dashboard session does not reach.
25
+ HOSTED_DASHBOARD_URL = "https://activeagents.ai"
23
26
  MAX_BYTES = 2 * 1024 * 1024
24
27
  DETAIL_LIMIT = 200
25
28
 
26
29
  # @return [String] the collector URL reports go to
27
30
  attr_reader :endpoint
28
31
 
32
+ # @return [String] the address a receipt's "url" path resolves against: the
33
+ # dashboard_url given, or by default the endpoint's origin (its scheme,
34
+ # host and any non-default port, with no path), except that the hosted
35
+ # API host resolves to HOSTED_DASHBOARD_URL
36
+ attr_reader :dashboard_url
37
+
29
38
  # Raised for every failed delivery. Only a network failure keeps the
30
39
  # underlying error as its +cause+, so neither the response nor the
31
40
  # report reaches a log through the exception chain.
@@ -68,7 +77,14 @@ module ActiveAgent
68
77
  # The key is sent as a bearer token and filtered from the collector's
69
78
  # explanation, so it is limited to visible ASCII: the sanitizer in
70
79
  # +collector_detail+ never alters it, and an echo of it always matches.
71
- def initialize(api_key:, endpoint: DEFAULT_ENDPOINT, timeout: 10, open_timeout: 10)
80
+ #
81
+ # @param dashboard_url [String, nil] the address people open the dashboard at,
82
+ # where a receipt's "url" path resolves; by default the endpoint's origin,
83
+ # or HOSTED_DASHBOARD_URL for the hosted collector.
84
+ # The receipt's path, which carries the engine's mount, replaces any path on it
85
+ # @raise [ArgumentError] "Evaluation dashboard_url is not an http(s) URL" when it is anything else, or
86
+ # "Evaluation dashboard_url must be an http(s) URL without credentials, query or fragment" when it carries one of those
87
+ def initialize(api_key:, endpoint: DEFAULT_ENDPOINT, dashboard_url: nil, timeout: 10, open_timeout: 10)
72
88
  @uri = URI.parse(endpoint.to_s)
73
89
  unless @uri.is_a?(URI::HTTP) && @uri.host && !@uri.userinfo && !@uri.query && !@uri.fragment
74
90
  raise ArgumentError, "Evaluation endpoint must be an HTTP(S) URL without credentials, query or fragment"
@@ -78,6 +94,8 @@ module ActiveAgent
78
94
  end
79
95
  @endpoint = @uri.to_s
80
96
 
97
+ @dashboard_url = dashboard_url.nil? ? default_dashboard_url(@uri) : checked_dashboard_url(dashboard_url)
98
+
81
99
  @api_key = api_key.to_s.strip
82
100
  raise ArgumentError, "Evaluation API key is required" if @api_key.empty?
83
101
  unless @api_key.match?(/\A[\x21-\x7E]+\z/)
@@ -93,9 +111,42 @@ module ActiveAgent
93
111
  raise ArgumentError, "Evaluation endpoint is not a valid URL"
94
112
  end
95
113
 
96
- # report may be a Report or its saved JSON hash. Full prompts, answers and
97
- # tool results are included; applications should make publication opt-in.
98
- def call(report:, run_id:, source:, agent_name:, suite:)
114
+ # Returns a publisher built from +env+: ACTIVEAGENTS_API_KEY is the key,
115
+ # ACTIVEAGENTS_EVALUATIONS_ENDPOINT the collector (DEFAULT_ENDPOINT when
116
+ # blank) and ACTIVEAGENTS_DASHBOARD_URL the dashboard a receipt's "url"
117
+ # path resolves against (the endpoint's origin when blank), so
118
+ # publishing is one line:
119
+ #
120
+ # Publisher.from_env.call(report: report, source: "support-app", agent_name: "SupportBot", suite: "support_desk")
121
+ #
122
+ # @param env [#[]] the environment to read, ENV by default
123
+ # @raise [ArgumentError] "ACTIVEAGENTS_API_KEY is not set" for a blank key, and what +new+ raises for the other two
124
+ # @return [Publisher]
125
+ def self.from_env(env = ENV)
126
+ api_key = env["ACTIVEAGENTS_API_KEY"].to_s.strip
127
+ raise ArgumentError, "ACTIVEAGENTS_API_KEY is not set" if api_key.empty?
128
+
129
+ endpoint = env["ACTIVEAGENTS_EVALUATIONS_ENDPOINT"].to_s.strip
130
+ dashboard_url = env["ACTIVEAGENTS_DASHBOARD_URL"].to_s.strip
131
+ new(api_key: api_key, endpoint: endpoint.empty? ? DEFAULT_ENDPOINT : endpoint, dashboard_url: dashboard_url.empty? ? nil : dashboard_url)
132
+ end
133
+
134
+ # Returns the collector's receipt for the completed +report+, a Report
135
+ # or its saved JSON hash. Full prompts, answers and tool results are
136
+ # included; applications should make publication opt-in.
137
+ #
138
+ # The receipt's "url" is the run's dashboard page, made absolute: a path
139
+ # beginning with "/" is joined onto +dashboard_url+. An absolute URL, a
140
+ # url that is neither (a relative reference without a leading slash, a
141
+ # network-path reference, or a path that is not a valid URI) and a
142
+ # receipt without one are left as the collector sent them.
143
+ #
144
+ # @param run_id [String, nil] the run's identity, kept across retries; by default
145
+ # the one the report's metadata records under "run_id" or :run_id, which
146
+ # Correlation#with_run puts there
147
+ # @raise [ArgumentError] when no run_id is given and the report's metadata records none as a nonempty string
148
+ def call(report:, run_id: nil, source:, agent_name:, suite:)
149
+ run_id = resolve_run_id(report, run_id)
99
150
  envelope = self.class.envelope(report: report, run_id: run_id, source: source, agent_name: agent_name, suite: suite)
100
151
  publish(envelope, accepted: %w[complete], receipt: "completion receipt")
101
152
  end
@@ -104,13 +155,18 @@ module ActiveAgent
104
155
  # which the collector shows as a running run under +run_id+. Each call
105
156
  # replaces the results the previous one sent; +call+ with the finished
106
157
  # report under the same run_id completes the run. The receipt's status
107
- # is "running", or "complete" when the run already finished.
158
+ # is "running", or "complete" when the run already finished, and its
159
+ # "url" is made absolute as +call+ makes it.
108
160
  #
109
161
  # Raises Error, without sending the report, when the collector does not
110
162
  # list "progress" among its capabilities (see +supports_progress?+): a
111
163
  # collector that predates progress would store the partial report as
112
164
  # complete.
113
- def progress(report:, run_id:, source:, agent_name:, suite:)
165
+ #
166
+ # @param run_id [String, nil] as for +call+: by default the one the report's metadata records
167
+ # @raise [ArgumentError] when no run_id is given and the report's metadata records none as a nonempty string
168
+ def progress(report:, run_id: nil, source:, agent_name:, suite:)
169
+ run_id = resolve_run_id(report, run_id)
114
170
  unless supports_progress?
115
171
  raise Error, "Evaluation collector does not accept progress; publish the finished report with call"
116
172
  end
@@ -186,8 +242,54 @@ module ActiveAgent
186
242
 
187
243
  private
188
244
 
245
+ # The run_id a delivery goes under: +run_id+ when given, otherwise the
246
+ # one the report's metadata records under a String or Symbol key, which
247
+ # Correlation#with_run puts there. Raises ArgumentError for a report
248
+ # that is neither a Report nor a hash, as +envelope+ does, and when
249
+ # neither +run_id+ nor the metadata names a nonempty string.
250
+ def resolve_run_id(report, run_id)
251
+ return run_id unless run_id.nil?
252
+
253
+ hash = report.to_h if report.respond_to?(:to_h) && !report.nil? && !report.is_a?(Array)
254
+ raise ArgumentError, "report must be a Report or its saved JSON hash" unless hash.is_a?(Hash)
255
+
256
+ metadata = hash["metadata"]
257
+ recorded = metadata["run_id"] || metadata[:run_id] if metadata.is_a?(Hash)
258
+ return recorded if recorded.is_a?(String) && !recorded.strip.empty?
259
+
260
+ raise ArgumentError, "run_id must be given, or the report's metadata must carry one (Correlation#with_run records it)"
261
+ end
262
+
263
+ # The hosted collector's API host serves no dashboard session, so its
264
+ # receipts resolve against the hosted dashboard; any other collector is
265
+ # its own dashboard.
266
+ def default_dashboard_url(uri)
267
+ uri.hostname == URI.parse(DEFAULT_ENDPOINT).hostname ? HOSTED_DASHBOARD_URL : origin(uri)
268
+ end
269
+
270
+ # Returns the dashboard_url given as a String, raising ArgumentError
271
+ # unless it is an http(s) URL with a host and without credentials, a
272
+ # query or a fragment.
273
+ def checked_dashboard_url(value)
274
+ uri = URI.parse(value.to_s)
275
+ raise ArgumentError, "Evaluation dashboard_url is not an http(s) URL" unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
276
+ if uri.userinfo || uri.query || uri.fragment
277
+ raise ArgumentError, "Evaluation dashboard_url must be an http(s) URL without credentials, query or fragment"
278
+ end
279
+
280
+ value.to_s
281
+ rescue URI::InvalidURIError
282
+ raise ArgumentError, "Evaluation dashboard_url is not an http(s) URL"
283
+ end
284
+
285
+ # The origin of +uri+: its scheme, host and any non-default port, with no path.
286
+ def origin(uri)
287
+ uri.dup.tap { |without_path| without_path.path = "" }.to_s
288
+ end
289
+
189
290
  # Delivers +envelope+ and returns the collector's receipt, which must name
190
- # the envelope's run_id, a stored run, and one of the +accepted+ statuses.
291
+ # the envelope's run_id, a stored run, and one of the +accepted+ statuses,
292
+ # with a "url" path made absolute on the dashboard.
191
293
  def publish(envelope, accepted:, receipt:)
192
294
  body = encode(envelope)
193
295
  raise Error, "Evaluation report exceeds the 2 MiB delivery limit; publish a smaller selection" if body.bytesize > MAX_BYTES
@@ -201,10 +303,24 @@ module ActiveAgent
201
303
  answer["id"] && answer["evaluation_id"]
202
304
  raise Error.new("Evaluation collector returned an invalid #{receipt}; retain the report and run_id for retry", retryable: true)
203
305
  end
204
- answer
306
+ resolve_url(answer)
205
307
  end
206
308
  end
207
309
 
310
+ # Returns +receipt+ with a "url" that is a path beginning with "/" joined
311
+ # onto +dashboard_url+. Any other url (an absolute one, a relative
312
+ # reference without a leading slash, a network-path reference, a path
313
+ # that is not a valid URI) and a receipt without one are left as the
314
+ # collector sent them.
315
+ def resolve_url(receipt)
316
+ url = receipt["url"]
317
+ return receipt unless url.is_a?(String) && url.start_with?("/") && !url.start_with?("//")
318
+
319
+ receipt.merge("url" => URI.join(@dashboard_url, url).to_s)
320
+ rescue URI::InvalidURIError
321
+ receipt
322
+ end
323
+
208
324
  # The string entries of a JSON object body's +capabilities+ array, or none.
209
325
  def collector_capabilities(body)
210
326
  parsed = JSON.parse(body.to_s)