activeagent 1.9.1 → 1.9.3

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,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Evals
5
+ # Whether the gap between the models of a run is larger than the noise of
6
+ # its scenario sample: each model's pass rate with a confidence interval,
7
+ # and an interval for the difference between every pair of models.
8
+ #
9
+ # The scenario is the unit. A model's pass rate is the mean over its
10
+ # scenarios of the share of its results there that passed (1.0 or 0.0
11
+ # for one result per scenario), and the intervals come from resampling
12
+ # whole scenarios with replacement, +resamples+ times: the percentile
13
+ # bootstrap. A pair is compared on the scenarios both models ran, each
14
+ # resample drawing the same scenarios for both (a paired bootstrap of
15
+ # the per-scenario differences). A pair is decisive when its interval
16
+ # excludes zero.
17
+ #
18
+ # The resampling is seeded, so the same results always give the same
19
+ # intervals and a report renders the same page each time.
20
+ #
21
+ # comparison = Comparison.new(results: report.results, models: report.models)
22
+ # comparison.to_h["pairs"].first
23
+ # # => { "models" => ["gpt-5-mini", "qwen3:8b"], "scenarios" => 42, "difference" => 7.1,
24
+ # # "interval" => [0.0, 16.7], "decisive" => false }
25
+ # comparison.conclusion
26
+ # # => "The pairwise difference interval includes zero: the ranking is not decisive evidence of superiority."
27
+ class Comparison
28
+ RESAMPLES = 5_000
29
+ CONFIDENCE = 0.95
30
+ SEED = 0
31
+ # Fewer scenarios than this give no interval: resampling one scenario
32
+ # only ever draws that scenario.
33
+ MINIMUM_SCENARIOS = 2
34
+
35
+ attr_reader :resamples, :confidence, :seed
36
+
37
+ # @param results [Array<Result>]
38
+ # @param models [Array<ModelSpec>] in the run's order, which breaks ties in the ranking
39
+ # @param resamples [Integer]
40
+ # @param confidence [Float] 0..1
41
+ # @param seed [Integer]
42
+ def initialize(results:, models:, resamples: RESAMPLES, confidence: CONFIDENCE, seed: SEED)
43
+ @labels = models.map(&:label)
44
+ @resamples = resamples
45
+ @confidence = confidence
46
+ @seed = seed
47
+ @outcomes = @labels.to_h do |label|
48
+ by_scenario = results.select { |result| result.label == label }.group_by { |result| result.scenario.key }
49
+ [ label, by_scenario.transform_values { |cohort| cohort.count(&:passed?).fdiv(cohort.size) } ]
50
+ end
51
+ end
52
+
53
+ # Per model label, in the run's order: `{ "scenarios", "pass_rate",
54
+ # "interval" }`, the pass rate and its interval in percent. The interval
55
+ # is nil below MINIMUM_SCENARIOS, and the pass rate nil for none.
56
+ def models
57
+ @models ||= @labels.to_h do |label|
58
+ values = @outcomes[label].values
59
+ [ label, {
60
+ "scenarios" => values.size,
61
+ "pass_rate" => percent(mean(values)),
62
+ "interval" => interval(values)
63
+ } ]
64
+ end
65
+ end
66
+
67
+ # The labels by pass rate, best first; ties keep the run's order.
68
+ def ranking
69
+ @ranking ||= @labels.sort_by.with_index { |label, index| [ -(models[label]["pass_rate"] || -1), index ] }
70
+ end
71
+
72
+ # One entry per pair of models, the better ranked first: `{ "models" =>
73
+ # [a, b], "scenarios", "difference", "interval", "decisive" }`, where
74
+ # "difference" is a's pass rate minus b's over the scenarios both ran,
75
+ # in percentage points.
76
+ def pairs
77
+ @pairs ||= ranking.combination(2).map do |a, b|
78
+ shared = @outcomes[a].keys & @outcomes[b].keys
79
+ differences = shared.map { |key| @outcomes[a][key] - @outcomes[b][key] }
80
+ range = interval(differences)
81
+
82
+ {
83
+ "models" => [ a, b ],
84
+ "scenarios" => shared.size,
85
+ "difference" => percent(mean(differences)),
86
+ "interval" => range,
87
+ "decisive" => !range.nil? && (range.first.positive? || range.last.negative?)
88
+ }
89
+ end
90
+ end
91
+
92
+ # The honest read of the ranking, in a sentence: which pairs differ by
93
+ # more than the noise, or that none does. nil for a single model.
94
+ def conclusion
95
+ return nil if pairs.empty?
96
+
97
+ compared, too_few = pairs.partition { |pair| pair["interval"] }
98
+ decisive, inconclusive = compared.partition { |pair| pair["decisive"] }
99
+ sentences =
100
+ if compared.empty?
101
+ [ "The models share too few scenarios to compare." ]
102
+ elsif decisive.empty?
103
+ [ "#{all_include_zero(compared.size)}: the ranking is not decisive evidence of superiority." ]
104
+ else
105
+ decisive.map { |pair| decisive_sentence(pair) } + (inconclusive.any? ? [ others_include_zero(inconclusive.size) ] : [])
106
+ end
107
+ sentences << "#{plural(too_few.size, 'pair')} share#{'s' if too_few.one?} too few scenarios to compare." if compared.any? && too_few.any?
108
+ sentences.join(" ")
109
+ end
110
+
111
+ # How the intervals were computed, for a caption.
112
+ def method_summary
113
+ { "statistic" => "pass rate", "interval" => "percentile bootstrap", "unit" => "scenario",
114
+ "resamples" => @resamples, "confidence" => @confidence, "seed" => @seed }
115
+ end
116
+
117
+ def to_h
118
+ { "method" => method_summary, "models" => models, "ranking" => ranking, "pairs" => pairs, "conclusion" => conclusion }
119
+ end
120
+
121
+ private
122
+
123
+ # The bootstrap interval of the mean of +values+, in percent, or nil
124
+ # below MINIMUM_SCENARIOS.
125
+ def interval(values)
126
+ return nil if values.size < MINIMUM_SCENARIOS
127
+
128
+ random = Random.new(@seed)
129
+ size = values.size
130
+ means = Array.new(@resamples) do
131
+ sum = 0.0
132
+ size.times { sum += values[random.rand(size)] }
133
+ sum / size
134
+ end
135
+ # A rational tail, so 95% leaves exactly 2.5: in floating point it
136
+ # is 2.5000000000000022, whose nearest rank is one too high.
137
+ tail = (1 - @confidence.rationalize) / 2 * 100
138
+ [ percent(Statistics.percentile(means, tail)), percent(Statistics.percentile(means, 100 - tail)) ]
139
+ end
140
+
141
+ def mean(values)
142
+ values.sum.fdiv(values.size) if values.any?
143
+ end
144
+
145
+ def percent(fraction)
146
+ (fraction * 100).round(1) if fraction
147
+ end
148
+
149
+ def all_include_zero(count)
150
+ count == 1 ? "The pairwise difference interval includes zero" : "All #{count} pairwise difference intervals include zero"
151
+ end
152
+
153
+ def others_include_zero(count)
154
+ count == 1 ? "The other pairwise interval includes zero." : "The other #{count} pairwise intervals include zero."
155
+ end
156
+
157
+ def decisive_sentence(pair)
158
+ better, worse = pair["difference"].positive? ? pair["models"] : pair["models"].reverse
159
+ low, high = pair["interval"].map(&:abs).sort
160
+ "#{better} passes more scenarios than #{worse}, by #{pair['difference'].abs} points " \
161
+ "(#{(@confidence * 100).round}% CI #{low} to #{high})."
162
+ end
163
+
164
+ def plural(count, word)
165
+ "#{count} #{word}#{'s' unless count == 1}"
166
+ end
167
+ end
168
+ end
169
+ end
@@ -94,11 +94,7 @@ module ActiveAgent
94
94
  # report may be a Report or its saved JSON hash. Full prompts, answers and
95
95
  # tool results are included; applications should make publication opt-in.
96
96
  def call(report:, run_id:, source:, agent_name:, suite:)
97
- identities = { "run_id" => run_id, "source" => source, "agent_name" => agent_name, "suite" => suite }
98
- identities.each do |key, value|
99
- raise ArgumentError, "#{key} must be a nonempty string" unless value.is_a?(String) && !value.strip.empty?
100
- end
101
- body = encode(identities.merge("version" => 1, "report" => report_hash(report)))
97
+ body = encode(self.class.envelope(report: report, run_id: run_id, source: source, agent_name: agent_name, suite: suite))
102
98
  raise Error, "Evaluation report exceeds the 2 MiB delivery limit; publish a smaller selection" if body.bytesize > MAX_BYTES
103
99
 
104
100
  deliver("retain the report and run_id for retry") do
@@ -143,6 +139,24 @@ module ActiveAgent
143
139
  end
144
140
  end
145
141
 
142
+ # The version-1 envelope a collector stores: the four identities, the
143
+ # version and the report's hash. What #call sends, and what a
144
+ # collector's export of a stored run returns, so the export can be
145
+ # posted again. report may be a Report or its saved JSON hash.
146
+ #
147
+ # Raises ArgumentError for an identity that is not a nonempty string,
148
+ # or a report that is neither.
149
+ def self.envelope(report:, run_id:, source:, agent_name:, suite:)
150
+ identities = { "run_id" => run_id, "source" => source, "agent_name" => agent_name, "suite" => suite }
151
+ identities.each do |key, value|
152
+ raise ArgumentError, "#{key} must be a nonempty string" unless value.is_a?(String) && !value.strip.empty?
153
+ end
154
+ hash = report.to_h if report.respond_to?(:to_h) && !report.nil? && !report.is_a?(Array)
155
+ raise ArgumentError, "report must be a Report or its saved JSON hash" unless hash.is_a?(Hash)
156
+
157
+ identities.merge("version" => 1, "report" => hash)
158
+ end
159
+
146
160
  private
147
161
 
148
162
  # Posts +body+ to the endpoint with the bearer key and the delivery timeouts.
@@ -176,13 +190,6 @@ module ActiveAgent
176
190
  raise Error.new("Evaluation delivery failed (#{e.class}); #{guidance}", retryable: true)
177
191
  end
178
192
 
179
- def report_hash(report)
180
- hash = report.to_h if report.respond_to?(:to_h) && !report.nil? && !report.is_a?(Array)
181
- raise ArgumentError, "report must be a Report or its saved JSON hash" unless hash.is_a?(Hash)
182
-
183
- hash
184
- end
185
-
186
193
  # Returns the envelope as JSON. A report holding invalid UTF-8, NaN,
187
194
  # Infinity or nesting over JSON's depth limit raises a non-retryable
188
195
  # Error, without the generator's message, which can quote the report.
@@ -8,6 +8,7 @@ module ActiveAgent
8
8
  # Markdown, or a self-contained HTML page (ReportHtml).
9
9
  class Report
10
10
  include ReportHtml
11
+ include ReportMarkdown
11
12
 
12
13
  # The judge a verdict names when the framework ranked the models by
13
14
  # pass rate itself, no judge having been available to rule on them.
@@ -37,9 +38,12 @@ module ActiveAgent
37
38
  # @param release [Hash, nil] the release of the agent under evaluation,
38
39
  # `{ "digest", "revision", "label" }`, so the report names the code
39
40
  # it scored: in `to_h["release"]` and as a header chip.
41
+ # @param comparison [Hash, nil] a comparison already recorded for these
42
+ # results (Comparison#to_h). A report rebuilt from a persisted run
43
+ # renders the intervals that run recorded instead of resampling them.
40
44
  def initialize(results:, models:, judge: nil, instructions: nil, threshold: PASS_THRESHOLD, metadata: {},
41
45
  tool_resolver: nil, agent_name: nil, links: {}, verdict: nil, judge_label: nil,
42
- judge_usage: nil, release: nil)
46
+ judge_usage: nil, release: nil, comparison: nil)
43
47
  @results = results
44
48
  @models = models
45
49
  @judge = judge
@@ -53,6 +57,7 @@ module ActiveAgent
53
57
  @recorded_verdict = verdict.is_a?(Hash) ? verdict.to_h.stringify_keys.presence : nil
54
58
  @run_judge_usage = judge_usage.is_a?(Hash) ? judge_usage.to_h.stringify_keys.presence : nil
55
59
  @release = release.is_a?(Hash) ? release.to_h.stringify_keys.compact.presence : nil
60
+ @recorded_comparison = comparison.is_a?(Hash) ? comparison.to_h.deep_stringify_keys.presence : nil
56
61
  end
57
62
 
58
63
  # The release of the agent this report scored, `{ "digest",
@@ -64,7 +69,8 @@ module ActiveAgent
64
69
  end
65
70
 
66
71
  # Per model, keyed by label: scenario count, passes, errors, pass rate,
67
- # mean score, mean latency, tokens, cost and fault counts. "cost" sums
72
+ # mean score, latency (mean, and the P50 and P95 by nearest rank,
73
+ # Statistics.percentile), tokens, cost and fault counts. "cost" sums
68
74
  # the replays that carried a cost — "priced" of the "scenarios", of
69
75
  # which "reported" came from the caller and "estimated" from tokens ×
70
76
  # a model rate (Result#cost_source) — and is nil when none did.
@@ -90,6 +96,8 @@ module ActiveAgent
90
96
  "avg_score" => scored.any? ? (scored.sum / scored.size).round(3) : nil,
91
97
  "avg_task_completion" => task_scores.any? ? (task_scores.sum / task_scores.size).round(3) : nil,
92
98
  "avg_duration_ms" => durations.any? ? (durations.sum.to_f / durations.size).round : nil,
99
+ "p50_duration_ms" => Statistics.percentile(durations, 50)&.round,
100
+ "p95_duration_ms" => Statistics.percentile(durations, 95)&.round,
93
101
  "input_tokens" => cohort.sum { |result| result.replay.input_tokens.to_i },
94
102
  "output_tokens" => cohort.sum { |result| result.replay.output_tokens.to_i },
95
103
  "cost" => costs.any? ? costs.sum.to_f.round(6) : nil,
@@ -267,14 +275,27 @@ module ActiveAgent
267
275
  verdict&.dig("winner")
268
276
  end
269
277
 
270
- # Adds "judge_usage" and "release" only when there is one to add, so
271
- # a report built the way it always was serializes exactly as before.
278
+ # Whether the gap between the models is real (Comparison#to_h): each
279
+ # model's pass rate with its interval, every pair's difference with
280
+ # its interval and whether it is decisive, and the conclusion. The
281
+ # comparison recorded for these results when one was handed in. nil
282
+ # for a single model.
283
+ def comparison
284
+ return nil unless comparing?
285
+
286
+ @comparison ||= @recorded_comparison || Comparison.new(results: @results, models: @models).to_h
287
+ end
288
+
289
+ # Adds "judge_usage" and "release" only when there is one to add, and
290
+ # "comparison" only when comparing models, so a single-model report
291
+ # built the way it always was serializes exactly as before.
272
292
  def to_h
273
293
  {
274
294
  "models" => summary_by_model,
275
295
  "criteria" => criterion_scores,
276
296
  "recommendations" => recommendations,
277
297
  "verdict" => verdict,
298
+ "comparison" => comparison,
278
299
  "judge" => @judge_label || @judge&.label,
279
300
  "judge_usage" => judge_usage,
280
301
  "release" => release,
@@ -287,23 +308,6 @@ module ActiveAgent
287
308
  JSON.pretty_generate(to_h, *args)
288
309
  end
289
310
 
290
- def to_markdown
291
- scenario_count = @results.map { |result| result.scenario.key }.uniq.size
292
- lines = [ "# Evaluation — #{scenario_count} scenario#{'s' unless scenario_count == 1} × #{@models.size} model#{'s' unless @models.size == 1}", "" ]
293
- label = @judge_label || @judge&.label
294
- lines << (label ? "Judged by `#{label}`." : "No judge; scored on rules and expectations alone.")
295
- lines << ""
296
- lines.concat(summary_table)
297
- lines << ""
298
- lines.concat(total_lines)
299
- lines << "**Best model: #{winner}**" if winner
300
- lines << ""
301
- lines.concat(matrix_table)
302
- lines.concat(detail_lines)
303
- lines.concat(recommendation_lines)
304
- lines.join("\n")
305
- end
306
-
307
311
  private
308
312
 
309
313
  # Whether a model summary's cost is an estimate — any replay priced
@@ -525,30 +529,43 @@ module ActiveAgent
525
529
 
526
530
  # --- Markdown ----------------------------------------------------------
527
531
 
528
- # The per-model table. A Judge column joins it when a judge spent
529
- # anything, so the agent's cost and the evaluation's stay two numbers.
532
+ # The per-model table. Cost / 1,000 puts every model on one
533
+ # denominator however many of its scenarios ran. A Judge column joins
534
+ # it when a judge spent anything, so the agent's cost and the
535
+ # evaluation's stay two numbers.
530
536
  def summary_table
531
537
  judged = judge_usage.present?
532
- columns = [ "Model", "Passed", "Mean score", "Mean latency", "Tokens in/out", "Cost", ("Judge" if judged), "Faults" ].compact
538
+ columns = [ "Model", "Passed", "Mean score", "Mean latency", "Median / P95", "Tokens in/out", "Cost", "Cost / 1,000", ("Judge" if judged), "Faults" ].compact
533
539
  header = [ "| #{columns.join(' | ')} |", "|#{columns.map { '---' }.join('|')}|" ]
534
540
  rows = summary_by_model.map do |label, stats|
535
541
  faults = stats["faults"].map { |fault, count| "#{fault.tr('_', ' ')} ×#{count}" }.join(", ")
536
542
  latency = stats["avg_duration_ms"] ? "#{stats['avg_duration_ms']} ms" : "—"
543
+ percentiles = stats["p50_duration_ms"] ? "#{stats['p50_duration_ms']} / #{stats['p95_duration_ms']} ms" : "—"
537
544
  cells = [
538
- "`#{label}`",
545
+ cell("`#{label}`"),
539
546
  Format.passes(stats["passed"], stats["scenarios"], style: :markdown),
540
547
  Format.score(stats["avg_score"]),
541
548
  latency,
549
+ percentiles,
542
550
  "#{stats['input_tokens']}/#{stats['output_tokens']}",
543
551
  Format.money(stats["cost"], estimated: estimated_cost?(stats)),
552
+ per_thousand_cell(stats),
544
553
  (judge_cell(stats) if judged),
545
- faults.presence || "—"
554
+ cell(faults.presence || "—")
546
555
  ].compact
547
556
  "| #{cells.join(' | ')} |"
548
557
  end
549
558
  header + rows
550
559
  end
551
560
 
561
+ # "$3.6300": what 1,000 scenario runs cost at the model's mean cost
562
+ # per priced replay, marked "~" when that mean is estimated or covers
563
+ # only some of the replays. "—" when none was priced.
564
+ def per_thousand_cell(stats)
565
+ per = cost_per_priced(stats)
566
+ per ? Format.money(per * 1_000, estimated: estimated_cost?(stats)) : "—"
567
+ end
568
+
552
569
  # "~$0.0030 (3 calls)" for a model's judge spend, "—" when the judge
553
570
  # was not asked about its results.
554
571
  def judge_cell(stats)
@@ -572,20 +589,52 @@ module ActiveAgent
572
589
  lines << ""
573
590
  end
574
591
 
592
+ # Each model's pass rate with its interval, best first, then every
593
+ # pair's difference with its interval, the conclusion, and how the
594
+ # intervals were computed. Nothing for a single model.
595
+ def comparison_lines
596
+ return [] unless comparison
597
+
598
+ method = comparison["method"]
599
+ confidence = "#{(method['confidence'].to_f * 100).round}% CI"
600
+ lines = [ "## Comparison", "", "| Model | Pass rate | #{confidence} | Scenarios |", "|---|---|---|---|" ]
601
+ comparison["ranking"].each do |label|
602
+ stats = comparison["models"][label]
603
+ lines << "| #{cell("`#{label}`")} | #{rate_text(stats['pass_rate'])} | #{interval_text(stats['interval'])} | #{stats['scenarios']} |"
604
+ end
605
+ lines += [ "", "| Pair | Difference | #{confidence} | Shared scenarios | Decisive |", "|---|---|---|---|---|" ]
606
+ comparison["pairs"].each do |pair|
607
+ a, b = pair["models"]
608
+ difference = pair["difference"] ? format("%+.1f points", pair["difference"]) : "—"
609
+ lines << "| #{cell("`#{a}` vs `#{b}`")} | #{difference} | #{interval_text(pair['interval'])} | #{pair['scenarios']} | #{pair['decisive'] ? 'yes' : 'no'} |"
610
+ end
611
+ lines += [ "", comparison["conclusion"].to_s, "",
612
+ "_#{confidence} from #{Format.delimited(method['resamples'])} resamples of whole scenarios (seed #{method['seed']})._", "" ]
613
+ end
614
+
615
+ def rate_text(rate)
616
+ rate ? "#{rate}%" : "—"
617
+ end
618
+
619
+ # "73.8 to 95.2", or "—" when there were too few scenarios.
620
+ def interval_text(interval)
621
+ interval ? "#{interval.first} to #{interval.last}" : "—"
622
+ end
623
+
575
624
  # The scenario matrix, with a trailing Cost column: what the scenario
576
625
  # cost across every model, and in brackets what the judge spent on it.
577
626
  def matrix_table
578
627
  labels = @models.map(&:label)
579
- header = [ "| Scenario | #{labels.map { |label| "`#{label}`" }.join(' | ')} | Cost |", "|---|#{labels.map { '---' }.join('|')}|---|" ]
628
+ header = [ "| Scenario | #{labels.map { |label| cell("`#{label}`") }.join(' | ')} | Cost |", "|---|#{labels.map { '---' }.join('|')}|---|" ]
580
629
  rows = @results.group_by { |result| result.scenario.key }.map do |key, cohort|
581
630
  cells = labels.map do |label|
582
631
  result = cohort.find { |candidate| candidate.label == label }
583
632
  next "—" unless result
584
633
 
585
634
  mark = result.passed? ? "✅" : (result.errored? ? "⚠️" : "❌")
586
- [ mark, (Format.score(result.score) if result.score), result.fault&.tr("_", " ") ].compact.join(" ")
635
+ cell([ mark, (Format.score(result.score) if result.score), result.fault&.tr("_", " ") ].compact.join(" "))
587
636
  end
588
- "| `#{key}` #{cell(cohort.first.scenario.prompt.truncate(70))} | #{cells.join(' | ')} | #{scenario_cost_cell(key)} |"
637
+ "| #{cell("`#{key}` #{cohort.first.scenario.prompt.truncate(70)}")} | #{cells.join(' | ')} | #{scenario_cost_cell(key)} |"
589
638
  end
590
639
  header + rows
591
640
  end
@@ -598,30 +647,11 @@ module ActiveAgent
598
647
  text
599
648
  end
600
649
 
601
- # A prompt may contain " | " (ScenarioParser keeps it), which would
602
- # otherwise split the table cell.
650
+ # A prompt may contain " | " (ScenarioParser keeps it), and an answer
651
+ # or a label a line break, either of which would otherwise break the
652
+ # table row.
603
653
  def cell(text)
604
- text.to_s.gsub("|") { "\\|" }
605
- end
606
-
607
- def detail_lines
608
- lines = [ "", "## Answers", "" ]
609
- @results.each do |result|
610
- lines << "### `#{result.scenario.key}` · `#{result.label}` · #{result.status}" \
611
- "#{" · score #{Format.score(result.score)}" if result.score}#{" · #{answer_cost(result)}" if result.replay.cost}"
612
- lines << ""
613
- lines << "> #{result.scenario.prompt}"
614
- lines << ""
615
- if result.replay.tool_calls.any?
616
- lines << "Tools: #{result.replay.tool_calls.map { |call| "#{call['name']}#{' ✗' if call['error']}" }.join(', ')}"
617
- end
618
- lines << "Fault: #{result.fault.tr('_', ' ')} — #{result.summary} #{result.recommendation}" if result.fault
619
- lines << "Error: #{result.replay.error}" if result.replay.error
620
- lines << ""
621
- lines << (result.replay.answer.presence || "_(no answer)_").to_s.truncate(1_500)
622
- lines << ""
623
- end
624
- lines
654
+ text.to_s.gsub(/\s*\r?\n\s*/, " ").gsub("|") { "\\|" }
625
655
  end
626
656
 
627
657
  # "~$0.0243 · judge ~$0.0015": what one answer cost, and what judging
@@ -632,20 +662,6 @@ module ActiveAgent
632
662
  text += " · judge #{Format.money(judge_cost, estimated: estimated_usage?(result.judge_usage))}" if judge_cost
633
663
  text
634
664
  end
635
-
636
- # Renders after `detail_lines`, whose trailing blank line separates the two sections.
637
- def recommendation_lines
638
- return [] if recommendations.empty?
639
-
640
- lines = [ "## Recommendations", "" ]
641
- recommendations.each do |entry|
642
- lines << "- **#{entry['fault'].tr('_', ' ')}** ×#{entry['count']} (#{entry['scenario_keys'].join(', ')}): #{entry['recommendation']}"
643
- entry["suggested_tools"].each do |tool|
644
- lines << " - suggested tool `#{tool['name']}`: #{tool['description']}"
645
- end
646
- end
647
- lines << ""
648
- end
649
665
  end
650
666
  end
651
667
  end