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.
@@ -5,10 +5,15 @@ require "cgi"
5
5
  module ActiveAgent
6
6
  module Evals
7
7
  # Report#to_html: the run as one self-contained page on the dashboard's
8
- # design system (DesignTokens) — inline styles, no scripts, no external
9
- # assets — so a run's outcome can be archived next to a CI run or handed
10
- # to a teammate, and so the dashboard can serve the same page for a
11
- # persisted run.
8
+ # design system (DesignTokens) — inline styles, no external assets — so a
9
+ # run's outcome can be archived next to a CI run or handed to a teammate,
10
+ # and so the dashboard can serve the same page for a persisted run.
11
+ #
12
+ # Its one inline script (COPY_SCRIPT) backs the "Copy as Markdown"
13
+ # buttons: the whole report, the verdict, What to fix (for the model the
14
+ # filter shows), each fix card and each scenario. The Markdown each copies
15
+ # is ReportMarkdown's, embedded in a hidden textarea, so the page copies
16
+ # exactly what the dashboard and the CLI write.
12
17
  #
13
18
  # Same content as Report#to_markdown, laid out the way the dashboard's
14
19
  # suite card is: header and stat tiles, the MODELS panel with the judge's
@@ -50,6 +55,8 @@ module ActiveAgent
50
55
  #{html_footer}
51
56
  </section>
52
57
  </main>
58
+ #{markdown_store("md-report", to_markdown)}
59
+ <script>#{COPY_SCRIPT}</script>
53
60
  </body>
54
61
  </html>
55
62
  HTML
@@ -151,6 +158,14 @@ module ActiveAgent
151
158
  "#{format("%.#{n >= 10_000 ? 1 : 2}f", n / 1_000).sub(/\.?0+\z/, '')}s"
152
159
  end
153
160
 
161
+ # "4.02s / 22.2s": a model's P50 and P95 latency; "—" when no replay
162
+ # was timed.
163
+ def fmt_percentiles(stats)
164
+ return "—" if stats["p50_duration_ms"].nil?
165
+
166
+ "#{fmt_ms(stats['p50_duration_ms'])} / #{fmt_ms(stats['p95_duration_ms'])}"
167
+ end
168
+
154
169
  # Money reads "~$0.0243" when any part of it was estimated from tokens
155
170
  # × a model rate rather than reported (Format.money); the footer's
156
171
  # legend explains the mark once.
@@ -229,7 +244,7 @@ module ActiveAgent
229
244
 
230
245
  <<~HEADER
231
246
  <header>
232
- <h1>#{h(title)}</h1>
247
+ <div class="title-row"><h1>#{h(title)}</h1>#{copy_button('md-report', label: 'Copy as Markdown', title: 'Copy the whole report as GitHub-flavored Markdown')}</div>
233
248
  <div class="chips">#{chips.join}</div>
234
249
  </header>
235
250
  HEADER
@@ -312,8 +327,10 @@ module ActiveAgent
312
327
 
313
328
  <<~PANEL
314
329
  <div class="panel">
315
- <div class="panel-head"><span class="micro">Models</span><span class="right">judged by #{h(judged_by)}</span></div>
330
+ <div class="panel-head"><span class="micro">Models</span><span class="right">judged by #{h(judged_by)}</span>#{copy_button('md-verdict', title: 'Copy the verdict and the model comparison as Markdown')}</div>
331
+ #{markdown_store('md-verdict', verdict_markdown)}
316
332
  #{html_comparison_table if comparing?}
333
+ #{html_intervals}
317
334
  #{blocks.join}
318
335
  #{verdict_row}
319
336
  </div>
@@ -335,12 +352,49 @@ module ActiveAgent
335
352
 
336
353
  <<~TABLE
337
354
  <div class="compare"><table>
338
- <thead><tr><th>Model</th><th class="num">Passed</th><th class="num">Mean score</th><th class="num">Avg latency</th><th class="num" title="Average input + output tokens per scenario">Avg tokens</th><th class="num" title="Cohort spend, and per scenario">Cost</th>#{judge_head}<th class="fault">Typical fault</th></tr></thead>
355
+ <thead><tr><th>Model</th><th class="num">Passed</th><th class="num">Mean score</th><th class="num">Avg latency</th><th class="num" title="Median and 95th percentile latency, nearest rank">P50 / P95</th><th class="num" title="Average input + output tokens per scenario">Avg tokens</th><th class="num" title="Cohort spend, and per scenario">Cost</th>#{judge_head}<th class="fault">Typical fault</th></tr></thead>
339
356
  <tbody>#{rows.map { |label, stats| html_comparison_row(label, stats) }.join}</tbody>
340
357
  </table></div>
341
358
  TABLE
342
359
  end
343
360
 
361
+ # The ranked interval plot under the comparison table (Report#comparison):
362
+ # one row per model, best first, its pass rate a dot inside its interval
363
+ # on a fixed 0-100% axis, so a narrow gap is not magnified, with the
364
+ # figures written out beside it; then the conclusion and how the
365
+ # intervals were computed. Nothing for a single model. The rows and the
366
+ # axis share one grid (.iv-row is display: contents), so every track
367
+ # spans the same width and a tick sits under the dots it names.
368
+ def html_intervals
369
+ return "" unless comparison
370
+
371
+ method = comparison["method"]
372
+ confidence = "#{(method['confidence'].to_f * 100).round}% CI"
373
+ rows = comparison["ranking"].map { |label| html_interval_row(label, comparison["models"][label] || {}) }
374
+ ticks = [ 0, 25, 50, 75, 100 ].map { |tick| %(<span class="iv-tick" style="left:#{tick}%">#{tick}%</span>) }
375
+
376
+ <<~PLOT
377
+ <div class="intervals">
378
+ <div class="micro sm">Pass rate · #{h(confidence)}</div>
379
+ #{rows.join}
380
+ <div class="iv-row iv-axis" aria-hidden="true"><span></span><span class="iv-track">#{ticks.join}</span><span></span></div>
381
+ <p class="iv-conclusion">#{h(comparison['conclusion'])}</p>
382
+ <p class="iv-caption">#{h(confidence)} from #{h(Format.delimited(method['resamples']))} resamples of whole scenarios</p>
383
+ </div>
384
+ PLOT
385
+ end
386
+
387
+ def html_interval_row(label, stats)
388
+ rate = stats["pass_rate"]
389
+ low, high = stats["interval"]
390
+ range = high ? %(<span class="iv-range" style="left:#{low}%;width:#{(high - low).round(1)}%"></span>) : ""
391
+ point = rate ? %(<span class="iv-point" style="left:#{rate}%"></span>) : ""
392
+ figures = "<b>#{rate ? "#{rate}%" : '—'}</b> #{high ? "#{low} to #{high}" : 'no interval'} · #{h(plural(stats['scenarios'].to_i, 'scenario'))}"
393
+
394
+ %(<div class="iv-row"><span class="iv-name">#{h(short_name(model_by_label(label)))}</span>) +
395
+ %(<span class="iv-track">#{range}#{point}</span><span class="iv-figures">#{figures}</span></div>\n)
396
+ end
397
+
344
398
  def html_comparison_row(label, stats)
345
399
  short, provider = split_label(model_by_label(label))
346
400
  total = stats["scenarios"].to_i
@@ -362,6 +416,7 @@ module ActiveAgent
362
416
  <td class="num ratio tone-#{tone_for(ratio)}">#{h(fmt_passes(stats['passed'], total))}</td>
363
417
  <td class="num">#{h(fmt_score(stats['avg_score']))}</td>
364
418
  <td class="num">#{h(fmt_ms(stats['avg_duration_ms']))}</td>
419
+ <td class="num">#{h(fmt_percentiles(stats))}</td>
365
420
  <td class="num"#{tokens_title}>#{tokens_cell}</td>
366
421
  <td class="num"#{cost_title_attr(results_for(label), estimated: estimated)}>#{cost_cell}</td>
367
422
  #{judge_cell}<td class="fault">#{typical_fault_text(label, stats)}</td>
@@ -417,7 +472,7 @@ module ActiveAgent
417
472
  <<~BLOCK
418
473
  <div class="model">
419
474
  <div class="line"><span class="name">#{h(short)}</span><span class="provider">#{h(provider)}</span>#{pick}<span class="pass"><span class="bar bar-#{tone}"><span style="width:#{(ratio * 100).round}%"></span></span><span class="ratio tone-#{tone}">#{h(fmt_passes(stats['passed'], total))}</span></span></div>
420
- <div class="stats-line"><span>score <b>#{h(fmt_score(stats['avg_score']))}</b></span><span>latency <b>#{h(fmt_ms(stats['avg_duration_ms']))}</b></span><span class="tok"><span class="in">in</span> #{h(fmt_k(stats['input_tokens']))} · <span class="out">out</span> #{h(fmt_k(stats['output_tokens']))}</span><span#{cost_title_attr(results_for(label), estimated: estimated)}>cost <b>#{h(fmt_cost(stats['cost'], estimated: estimated))}</b></span>#{judge_line}</div>
475
+ <div class="stats-line"><span>score <b>#{h(fmt_score(stats['avg_score']))}</b></span><span>latency <b>#{h(fmt_ms(stats['avg_duration_ms']))}</b></span><span>p50 / p95 <b>#{h(fmt_percentiles(stats))}</b></span><span class="tok"><span class="in">in</span> #{h(fmt_k(stats['input_tokens']))} · <span class="out">out</span> #{h(fmt_k(stats['output_tokens']))}</span><span#{cost_title_attr(results_for(label), estimated: estimated)}>cost <b>#{h(fmt_cost(stats['cost'], estimated: estimated))}</b></span>#{judge_line}</div>
421
476
  <div class="faults">#{faults_html}</div>
422
477
  </div>
423
478
  BLOCK
@@ -565,12 +620,13 @@ module ActiveAgent
565
620
  expects = scenario.expected_tools.any? ? %(<span class="exp">expects <b>#{h(scenario.expected_tools.join(' or '))}</b></span>) : ""
566
621
  labels = @models.map(&:label)
567
622
  cards = cohort.sort_by { |result| labels.index(result.label) || labels.size }.map { |result| html_result_card(result) }
623
+ md_id = "md-#{anchor(scenario)}"
568
624
 
569
625
  <<~BLOCK
570
626
  <div id="#{h(anchor(scenario))}">
571
627
  <details>
572
628
  <summary><span class="chev">&gt;</span><span class="key">#{h(scenario.key)}</span><span class="prompt">#{h(scenario.prompt)}</span>#{expects}</summary>
573
- <div class="drill">#{cards.join}</div>
629
+ <div class="drill">#{cards.join}<div class="drill-copy">#{copy_button(md_id, title: "Copy #{scenario.key} as Markdown")}#{markdown_store(md_id, scenario_markdown(scenario.key, answer_limit: ANSWER_LIMIT))}</div></div>
574
630
  </details>
575
631
  </div>
576
632
  BLOCK
@@ -623,14 +679,16 @@ module ActiveAgent
623
679
  "#{plural(faulted.map { |result| result.scenario.key }.uniq.size, 'scenario')}"
624
680
  body =
625
681
  if items.any?
626
- %(<div class="fixes">#{items.map { |item| html_fix_card(item) }.join}</div>)
682
+ %(<div class="fixes">#{items.each_with_index.map { |item, index| html_fix_card(item, index) }.join}</div>)
627
683
  else
628
684
  %(<div class="nothing">[+] nothing to fix</div>)
629
685
  end
630
686
 
631
687
  <<~FIXES
632
688
  <section class="section fix-section" aria-label="Recommendations">
633
- <div class="section-head"><span class="micro">What to fix</span><span class="meta">#{h(meta)}</span></div>
689
+ <div class="section-head"><span class="micro">What to fix</span><span class="meta">#{h(meta)}</span>#{copy_button('md-fixes', model_filtered: comparing? && items.any?, title: 'Copy What to fix as Markdown, for the model the filter shows')}</div>
690
+ #{markdown_store('md-fixes', fix_items_markdown)}
691
+ #{@models.each_index.map { |index| markdown_store("md-fixes-m#{index}", fix_items_markdown(model: @models[index].label)) }.join if comparing? && items.any?}
634
692
  #{html_fix_filter(items) if comparing? && items.any?}
635
693
  #{body}
636
694
  </section>
@@ -659,7 +717,7 @@ module ActiveAgent
659
717
  labels.filter_map { |label| (index = @models.index(model_by_label(label))) && "m#{index}" }.join(" ")
660
718
  end
661
719
 
662
- def html_fix_card(item)
720
+ def html_fix_card(item, index)
663
721
  tone = item["kind"] == "instruction" ? "info" : "error"
664
722
  glyph = tone == "info" ? "[i]" : "[!]"
665
723
  title = fault_name(item["fault"]) + (item["count"].to_i > 1 ? " ×#{item['count']}" : "")
@@ -672,6 +730,7 @@ module ActiveAgent
672
730
  parts << html_fix_server(item["server"]) if item["server"]
673
731
  parts << %(<div class="note">#{h(item['note'])}</div>) if item["note"].present?
674
732
  parts << html_fix_action(item["action"]) if item["action"]
733
+ parts << %(<div class="fix-copy">#{copy_button("md-fix-#{index}", title: 'Copy this fix as Markdown')}#{markdown_store("md-fix-#{index}", fix_item_markdown(item))}</div>)
675
734
  models = fix_model_tokens(item)
676
735
  %(<div class="fix"#{%( data-models="#{models}") if models.present?}>#{parts.join}</div>)
677
736
  end
@@ -724,6 +783,19 @@ module ActiveAgent
724
783
  "#{scenarios} · #{models}"
725
784
  end
726
785
 
786
+ # --- Copy as Markdown -------------------------------------------------
787
+
788
+ # A button COPY_SCRIPT answers by copying the textarea +id+ names; with
789
+ # +model_filtered+, the one for the model the fix filter has checked.
790
+ def copy_button(id, label: "Copy as Markdown", title: nil, model_filtered: false)
791
+ %(<button type="button" class="copy-md" data-md="#{h(id)}"#{' data-md-model' if model_filtered}) +
792
+ %(#{%( title="#{h(title)}") if title}>#{h(label)}</button>)
793
+ end
794
+
795
+ def markdown_store(id, markdown)
796
+ %(<textarea class="md-store" id="#{h(id)}" hidden readonly>#{h(markdown)}</textarea>)
797
+ end
798
+
727
799
  # --- footer ----------------------------------------------------------
728
800
 
729
801
  # The run's terms: the judge, the criteria and the pass mark, what it
@@ -757,6 +829,48 @@ module ActiveAgent
757
829
 
758
830
  # Colors only through the token variables; radii 4 badges · 6 chips ·
759
831
  # 8 controls · 10 nested panels · 12 cards · 999 bars; no shadows.
832
+ # Copies the Markdown a button names: the async Clipboard API where the
833
+ # page may use it, else a selected textarea and the copy command, which
834
+ # still works on an http:// host and in the dashboard's frame.
835
+ COPY_SCRIPT = <<~JS.gsub(/\n\s*/, "").freeze
836
+ document.addEventListener("click", function (event) {
837
+ var button = event.target.closest && event.target.closest("button.copy-md");
838
+ if (!button) return;
839
+ event.preventDefault();
840
+ var id = button.getAttribute("data-md");
841
+ if (button.hasAttribute("data-md-model")) {
842
+ var picked = document.querySelector('input[name="fix-model"]:checked');
843
+ if (picked && picked.value !== "all") id += "-" + picked.value;
844
+ }
845
+ var store = document.getElementById(id);
846
+ if (!store) return;
847
+ var text = store.value;
848
+ var label = button.getAttribute("data-label") || button.textContent;
849
+ button.setAttribute("data-label", label);
850
+ var done = function (ok) {
851
+ button.textContent = ok ? "Copied" : "Copy failed";
852
+ setTimeout(function () { button.textContent = label; }, 1500);
853
+ };
854
+ var legacy = function () {
855
+ var area = document.createElement("textarea");
856
+ area.value = text;
857
+ area.style.position = "fixed";
858
+ area.style.opacity = "0";
859
+ document.body.appendChild(area);
860
+ area.select();
861
+ var ok = false;
862
+ try { ok = document.execCommand("copy"); } catch (error) {}
863
+ document.body.removeChild(area);
864
+ done(ok);
865
+ };
866
+ if (navigator.clipboard && navigator.clipboard.writeText) {
867
+ navigator.clipboard.writeText(text).then(function () { done(true); }, legacy);
868
+ } else {
869
+ legacy();
870
+ }
871
+ });
872
+ JS
873
+
760
874
  STYLES = <<~CSS.freeze
761
875
  * { box-sizing: border-box; }
762
876
  html, body { margin: 0; min-height: 100%; }
@@ -830,6 +944,21 @@ module ActiveAgent
830
944
  .pick-model input { position: absolute; opacity: 0; width: 0; height: 0; }
831
945
  .pick-model:has(input:checked) { border-color: var(--color-accent-ui); background: var(--color-accent-ui-tint); color: var(--color-accent-ui); }
832
946
  .clean { font-family: var(--font-mono); font-size: 11px; color: var(--color-success-text); }
947
+ .intervals { padding: 10px 12px; border-top: 1px solid var(--color-border-light); display: grid; grid-template-columns: minmax(120px, 200px) minmax(160px, 1fr) minmax(190px, auto); align-items: center; gap: 6px 12px; }
948
+ .intervals > .micro, .iv-conclusion, .iv-caption { grid-column: 1 / -1; }
949
+ .iv-row { display: contents; }
950
+ .iv-name { font-family: var(--font-mono); font-size: 12px; font-weight: 600; color: var(--color-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
951
+ .iv-track { position: relative; height: 14px; }
952
+ .iv-track::before { content: ""; position: absolute; left: 0; right: 0; top: 6px; height: 2px; background: var(--color-muted); }
953
+ .iv-range { position: absolute; top: 3px; height: 8px; box-sizing: border-box; border: 1px solid var(--color-info); border-radius: 999px; background: var(--color-info-soft); }
954
+ .iv-point { position: absolute; top: 2px; width: 10px; height: 10px; margin-left: -5px; border-radius: 50%; background: var(--color-info); }
955
+ .iv-figures { font-family: var(--font-mono); font-size: 11px; color: var(--color-text-secondary); white-space: nowrap; }
956
+ .iv-figures b { color: var(--color-text-primary); }
957
+ .iv-axis .iv-track::before { display: none; }
958
+ .iv-tick { position: absolute; top: 0; transform: translateX(-50%); font-family: var(--font-mono); font-size: 10px; color: var(--color-text-muted); }
959
+ .iv-conclusion { margin: 4px 0 0; font-size: 12px; line-height: 18px; color: var(--color-text-cell); }
960
+ .iv-caption { margin: 0; font-family: var(--font-mono); font-size: 10px; color: var(--color-text-muted); }
961
+ @media (max-width: 640px) { .intervals { grid-template-columns: minmax(0, 1fr); row-gap: 4px; } .iv-row + .iv-row .iv-name { margin-top: 6px; } .iv-axis { display: none; } }
833
962
  .verdict { padding: 10px 12px; border-top: 1px solid var(--color-border-light); font-size: 12px; line-height: 18px; color: var(--color-text-cell); }
834
963
  .verdict .micro { margin-right: 8px; }
835
964
  .fixes { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 12px; }
@@ -851,6 +980,14 @@ module ActiveAgent
851
980
  .action { display: flex; align-items: center; gap: 10px; margin-top: auto; padding-top: 2px; flex-wrap: wrap; }
852
981
  .btn { display: inline-block; padding: 6px 12px; border-radius: 8px; font-size: 13px; font-weight: 500; color: var(--color-text-cell); border: 1px solid var(--color-border-strong); background: transparent; white-space: nowrap; }
853
982
  .btn:hover { background: var(--color-hover); color: var(--color-text-primary); text-decoration: none; }
983
+ .copy-md { font-family: var(--font-mono); font-size: 11px; padding: 3px 8px; border-radius: 6px; border: 1px solid var(--color-border); background: transparent; color: var(--color-text-secondary); cursor: pointer; white-space: nowrap; }
984
+ .copy-md:hover { background: var(--color-hover); color: var(--color-text-primary); }
985
+ .title-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
986
+ .section-head .copy-md, .panel-head .copy-md { margin-left: auto; }
987
+ .panel-head .right + .copy-md { margin-left: 0; }
988
+ .drill-copy, .fix-copy { display: flex; justify-content: flex-end; }
989
+ .drill-copy { grid-column: 1 / -1; }
990
+ @media print { .copy-md { display: none; } }
854
991
  .hint { font-family: var(--font-mono); font-size: 11px; color: var(--color-text-muted); white-space: nowrap; }
855
992
  .matrix { border: 1px solid var(--color-border-light); border-radius: 10px; overflow-x: auto; }
856
993
  .mx { display: grid; gap: 12px; padding: 10px 12px; border-top: 1px solid var(--color-border-light); }
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Evals
5
+ # Report#to_markdown: the run as GitHub-flavored Markdown with the same
6
+ # content as Report#to_html — the per-model table and the cost, the
7
+ # verdict with the model comparison, the scenario matrix, every answer
8
+ # (one `<details>` block per scenario, since GitHub strips the HTML
9
+ # page's styles), and What to fix from Report#fix_items — so it can be
10
+ # pasted into a pull request or an issue.
11
+ #
12
+ # Each piece is public on its own, for the pieces people paste one at a
13
+ # time: #fix_item_markdown, #fix_items_markdown (optionally for one
14
+ # model), #verdict_markdown and #scenario_markdown. The whole report is
15
+ # built from them, so a piece copied alone reads as it does in the report.
16
+ #
17
+ # Table cells escape `|` and fold line breaks; text set inside the HTML
18
+ # of a `<details>` block escapes `<`, `>` and `&`.
19
+ module ReportMarkdown
20
+ # How much of an answer the whole report quotes; a scenario copied on
21
+ # its own quotes the whole answer.
22
+ MARKDOWN_ANSWER_LIMIT = 1_500
23
+
24
+ def to_markdown
25
+ lines = [ "# #{markdown_title}", "" ]
26
+ lines << judged_by_line
27
+ lines << ""
28
+ lines.concat(summary_table)
29
+ lines << ""
30
+ lines.concat(total_lines)
31
+ lines.concat(verdict_lines)
32
+ lines.concat(comparison_lines)
33
+ lines.concat(matrix_table)
34
+ lines.concat([ "", "## Answers", "" ])
35
+ scenario_keys.each { |key| lines << scenario_markdown(key, details: true, answer_limit: MARKDOWN_ANSWER_LIMIT) << "" }
36
+ lines << fix_items_markdown(heading: "##")
37
+ "#{lines.join("\n").rstrip}\n"
38
+ end
39
+
40
+ # One fix item (Report#fix_items) as a heading and its body: the fault
41
+ # and how often, the scenarios and models it covers, the
42
+ # recommendation, the judge's proposed instruction change as a quote,
43
+ # the tools involved, the server that serves them, the note and where
44
+ # in the dashboard the fix lives.
45
+ def fix_item_markdown(item, heading: "###")
46
+ item = item.to_h.stringify_keys
47
+ title = item["fault"].to_s.tr("_", " ") + (item["count"].to_i > 1 ? " ×#{item['count']}" : "")
48
+ lines = [ "#{heading} #{item['kind'] == 'instruction' ? 'ℹ️' : '❗'} #{title}", "" ]
49
+
50
+ scope = [ "Scenarios: #{Array(item['scenario_keys']).map { |key| "`#{key}`" }.join(', ')}" ]
51
+ scope << "models: #{Array(item['models']).map { |label| "`#{label}`" }.join(', ')}" if Array(item["models"]).any?
52
+ scope << "judge suggestion" if item["kind"] == "instruction"
53
+ lines << scope.join(" · ") << ""
54
+ lines << item["recommendation"].to_s.strip << "" if item["recommendation"].present?
55
+ lines.concat(quoted(item["quote"]).push("")) if item["quote"].present?
56
+ tools = Array(item["tools"])
57
+ if tools.any?
58
+ lines << "**#{item['tools_label'].presence || 'tools'}:** " + tools.map { |tool| tool_text(tool) }.join(", ")
59
+ lines << ""
60
+ end
61
+ if (server = item["server"])
62
+ state = server["status"] == "enabled" ? "enabled for #{@agent_name}" : "#{server['status'].presence || 'unknown'} · not enabled for #{@agent_name}"
63
+ lines << "Served by **#{server['name'].presence || server['key']}** (#{state})" << ""
64
+ end
65
+ lines << "_#{item['note'].to_s.strip}_" << "" if item["note"].present?
66
+ if (action = item["action"])
67
+ lines << "Fix: #{[ action['label'], action['hint'] ].compact_blank.join(' — ')}" << ""
68
+ end
69
+ lines.join("\n").rstrip
70
+ end
71
+
72
+ # What to fix: every fix item, or with +model+ (a model label) the ones
73
+ # attributed to that model, as the report's model filter shows them.
74
+ def fix_items_markdown(model: nil, heading: "##")
75
+ items = fix_items
76
+ items = items.select { |item| Array(item["models"]).empty? || item["models"].include?(model) } if model.present?
77
+ lines = [ "#{heading} What to fix#{" for `#{model}`" if model.present?}", "" ]
78
+ if items.empty?
79
+ lines << "_Nothing to fix._"
80
+ else
81
+ sub = "#{heading}#"
82
+ lines << items.map { |item| fix_item_markdown(item, heading: sub) }.join("\n\n")
83
+ end
84
+ lines.join("\n")
85
+ end
86
+
87
+ # The verdict — the best model, why, and who picked it — then the
88
+ # per-model table and, comparing models, the comparison with its
89
+ # intervals.
90
+ def verdict_markdown(heading: "##")
91
+ lines = [ "#{heading} Verdict", "", judged_by_line, "" ]
92
+ lines.concat(verdict_lines)
93
+ lines.concat(summary_table)
94
+ lines << ""
95
+ lines.concat(total_lines)
96
+ lines.concat(comparison_lines.map { |line| line.start_with?("## ") ? line.sub("## ", "#{heading}# ") : line })
97
+ lines.join("\n").rstrip
98
+ end
99
+
100
+ # One scenario: its prompt and expectations, a table of each model's
101
+ # status, score, tool calls, fault and cost, then each model's answer.
102
+ # With +details+ it is a `<details>` block whose summary names the key,
103
+ # the prompt and each model's mark, the way the report folds it.
104
+ # Raises KeyError for a key the report has no result for.
105
+ def scenario_markdown(key, details: false, answer_limit: nil)
106
+ cohort = @results.select { |result| result.scenario.key == key.to_s }
107
+ raise KeyError, "no results for scenario #{key}" if cohort.empty?
108
+
109
+ scenario = cohort.first.scenario
110
+ labels = @models.map(&:label)
111
+ cohort = cohort.sort_by { |result| labels.index(result.label) || labels.size }
112
+ body = []
113
+ body << "**Prompt**" << "" << quoted(scenario.prompt).join("\n") << ""
114
+ expectations = expectation_lines(scenario)
115
+ body.concat(expectations).push("") if expectations.any?
116
+ body << "| Model | Status | Score | Tools | Fault | Cost |" << "|---|---|---|---|---|---|"
117
+ cohort.each do |result|
118
+ tools = result.replay.tool_calls.map { |call| "#{call['name']}#{' ✗' if call['error']}" }.join(", ")
119
+ body << "| #{[ "`#{result.label}`", status_text(result), (result.score ? Format.score(result.score) : '—'), tools.presence || '—',
120
+ result.fault&.tr('_', ' ') || '—', (result.replay.cost ? answer_cost(result) : '—') ].map { |text| cell(text) }.join(' | ')} |"
121
+ end
122
+ body << ""
123
+ cohort.each do |result|
124
+ body << "**`#{result.label}`** — #{status_text(result)}"
125
+ body << ""
126
+ body << "Fault: #{result.fault.tr('_', ' ')} — #{[ result.summary, result.recommendation ].compact.join(' ')}" << "" if result.fault
127
+ body << "Error: #{result.replay.error}" << "" if result.replay.error
128
+ answer = result.replay.answer.to_s
129
+ answer = answer.truncate(answer_limit) if answer_limit
130
+ body << (answer.present? ? quoted(answer).join("\n") : "_(no answer)_") << ""
131
+ end
132
+ text = body.join("\n").rstrip
133
+ return "### Scenario `#{scenario.key}`\n\n#{text}" unless details
134
+
135
+ marks = cohort.map { |result| "#{status_mark(result)} #{result.label}" }.join(" · ")
136
+ summary = "<code>#{html_text(scenario.key)}</code> #{html_text(scenario.prompt.truncate(80))} — #{html_text(marks)}"
137
+ "<details>\n<summary>#{summary}</summary>\n\n#{text}\n\n</details>"
138
+ end
139
+
140
+ private
141
+
142
+ def markdown_title
143
+ scenarios = scenario_keys.size
144
+ "Evaluation — #{scenarios} scenario#{'s' unless scenarios == 1} × #{@models.size} model#{'s' unless @models.size == 1}"
145
+ end
146
+
147
+ def scenario_keys
148
+ @results.map { |result| result.scenario.key }.uniq
149
+ end
150
+
151
+ def judged_by_line
152
+ label = @judge_label || @judge&.label
153
+ label ? "Judged by `#{label}`." : "No judge; scored on rules and expectations alone."
154
+ end
155
+
156
+ # "**Best model: gpt-5-mini**" with the verdict's rationale under it
157
+ # and, when a judge (not the pass-rate ranking) wrote it, who did.
158
+ def verdict_lines
159
+ return [] unless winner
160
+
161
+ lines = [ "**Best model: #{winner}**", "" ]
162
+ rationale = verdict["rationale"].to_s.strip
163
+ judge = verdict["judge"].presence
164
+ rationale += " _(#{judge == Report::PASS_RATE_JUDGE ? 'ranked by pass rate' : "judged by #{judge}"})_" if judge && rationale.present?
165
+ lines << rationale << "" if rationale.present?
166
+ lines
167
+ end
168
+
169
+ def expectation_lines(scenario)
170
+ lines = []
171
+ lines << "Expects tools: #{scenario.expected_tools.map { |name| "`#{name}`" }.join(' or ')}" if scenario.expected_tools.any?
172
+ lines << "Expects the answer to contain: #{scenario.expected_patterns.map { |text| "`#{text}`" }.join(', ')}" if scenario.expected_patterns.any?
173
+ lines << "Expects the answer not to contain: #{scenario.forbidden_patterns.map { |text| "`#{text}`" }.join(', ')}" if scenario.forbidden_patterns.any?
174
+ lines.map { |line| "- #{line}" }
175
+ end
176
+
177
+ def status_mark(result)
178
+ result.passed? ? "✅" : (result.errored? ? "⚠️" : "❌")
179
+ end
180
+
181
+ def status_text(result)
182
+ "#{status_mark(result)} #{result.status}"
183
+ end
184
+
185
+ def tool_text(tool)
186
+ tool = tool.is_a?(Hash) ? tool.stringify_keys : { "name" => tool.to_s }
187
+ note = tool["note"].presence
188
+ "`#{tool['name']}`#{" (#{note})" if note}"
189
+ end
190
+
191
+ # Each line of +text+ as a blockquote line, `<` and `>` escaped so an
192
+ # answer cannot open or close the HTML around it.
193
+ def quoted(text)
194
+ text.to_s.strip.split(/\r?\n/).map { |line| line.strip.empty? ? ">" : "> #{line.gsub('<', '&lt;').gsub('>', '&gt;')}" }
195
+ end
196
+
197
+ def html_text(text)
198
+ text.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub(/\s+/, " ")
199
+ end
200
+ end
201
+ end
202
+ end
@@ -54,10 +54,14 @@ module ActiveAgent
54
54
  # @param release [Hash, nil] the release of the agent under evaluation,
55
55
  # `{ "digest", "revision", "label" }`, carried onto the Report so a
56
56
  # published run names the code it scored (see Report.new)
57
+ # @param refine_gate [#call, nil] asked before each recommendation call,
58
+ # after `judge_limit`; false skips it. A caller that scores each
59
+ # scenario in its own Runner (a background job per scenario) shares one
60
+ # run-wide limit this way, since `judge_limit` counts per instance.
57
61
  def initialize(scenarios:, models:, replay:, criteria: [], judge: nil, judge_task: true, available_tools: {},
58
62
  instructions: nil, agent_name: "The agent", threshold: PASS_THRESHOLD,
59
63
  refine_faults: DEFAULT_REFINE_FAULTS, judge_limit: DEFAULT_JUDGE_LIMIT, on_result: nil,
60
- around_evaluation: nil, require_judge_scores: false, metadata: {}, release: nil)
64
+ around_evaluation: nil, require_judge_scores: false, metadata: {}, release: nil, refine_gate: nil)
61
65
  @scenarios = scenarios
62
66
  @models = models
63
67
  @replay = replay
@@ -75,6 +79,7 @@ module ActiveAgent
75
79
  @require_judge_scores = require_judge_scores
76
80
  @metadata = metadata
77
81
  @release = release
82
+ @refine_gate = refine_gate
78
83
  @judge_calls = 0
79
84
  @scorer = Scorer.new(criteria: criteria, judge: judge)
80
85
  end
@@ -187,6 +192,7 @@ module ActiveAgent
187
192
  def refine!(diagnosis, scenario, replay, result)
188
193
  return unless @judge && @refine_faults.include?(result.fault)
189
194
  return if @judge_calls >= @judge_limit
195
+ return if @refine_gate && !@refine_gate.call
190
196
 
191
197
  @judge_calls += 1
192
198
  refined = @judge.recommend(scenario: scenario, replay: replay, diagnosis: result,
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Evals
5
+ # The summary statistics a report computes over its results.
6
+ module Statistics
7
+ module_function
8
+
9
+ # Nearest-rank percentile: the value at rank ceil(pct / 100 × n) of the
10
+ # values sorted ascending, so the result is always one of the values. The
11
+ # dashboard's Metrics view uses the same rule (ActionAgent::MetricsReport),
12
+ # so a model's P95 reads the same in both. nil for no values.
13
+ #
14
+ # @param values [Array<Numeric>] in any order; nils are ignored
15
+ # @param pct [Numeric] 0..100
16
+ # @return [Numeric, nil]
17
+ def percentile(values, pct)
18
+ sorted = values.compact.sort
19
+ return nil if sorted.empty?
20
+
21
+ rank = (sorted.size * pct / 100.0).ceil
22
+ sorted[rank.clamp(1, sorted.size) - 1]
23
+ end
24
+ end
25
+ end
26
+ end
@@ -19,12 +19,15 @@ require_relative "evals/catalog"
19
19
  require_relative "evals/model_spec"
20
20
  require_relative "evals/replay"
21
21
  require_relative "evals/format"
22
+ require_relative "evals/statistics"
23
+ require_relative "evals/comparison"
22
24
  require_relative "evals/scorer"
23
25
  require_relative "evals/diagnosis"
24
26
  require_relative "evals/judge"
25
27
  require_relative "evals/result"
26
28
  require_relative "evals/design_tokens"
27
29
  require_relative "evals/report_html"
30
+ require_relative "evals/report_markdown"
28
31
  require_relative "evals/report"
29
32
  require_relative "evals/runner"
30
33
  require_relative "evals/correlation"
@@ -132,6 +132,7 @@ module ActiveAgent
132
132
  :stream_broadcaster, :streaming, # Callback (Streams)
133
133
  :stream_completion_pending, # Callback (Streams)
134
134
  :stream_completion_result, # Callback (Streams)
135
+ :stream_finish_reason, # Callback (Streams)
135
136
  :tools_function, # Callback (Tools)
136
137
  :usage_stack, # Usage Tracking
137
138
  :stream_usage_index, # Usage Tracking (Streams)
@@ -607,6 +608,7 @@ module ActiveAgent
607
608
  messages:,
608
609
  raw_request: prompt_request_type.serialize(request),
609
610
  raw_response: api_response,
611
+ streamed_finish_reason: stream_finish_reason,
610
612
  usages: usage_stack,
611
613
  **attributes
612
614
  )
@@ -43,7 +43,7 @@ module ActiveAgent
43
43
  def initialize(kwargs = {})
44
44
  super
45
45
 
46
- self.json_format_retry_count = kwargs[:max_retries] || ::Anthropic::Client::DEFAULT_MAX_RETRIES
46
+ self.json_format_retry_count = options.max_retries
47
47
  end
48
48
 
49
49
  # @todo Add support for Anthropic::BedrockClient and Anthropic::VertexClient
@@ -222,6 +222,8 @@ module ActiveAgent
222
222
  # No-Op; Wait for Function call to be complete
223
223
  when :thinking_delta, :signature_delta
224
224
  # TODO: Add with thinking rendering support
225
+ when :citations_delta
226
+ # No-Op; the citations arrive with the full block at content_block_stop
225
227
  else
226
228
  raise "Unexpected delta type: #{api_delta.type}"
227
229
  end
@@ -235,6 +237,7 @@ module ActiveAgent
235
237
  when :message_delta
236
238
  delta = Anthropic::Transforms.gem_to_hash(api_response_chunk.delta)
237
239
  self.message_stack.last.merge!(delta)
240
+ self.stream_finish_reason = delta[:stop_reason] if delta[:stop_reason]
238
241
 
239
242
  # Message Completed [Full Message]
240
243
  when :message_stop
@@ -63,6 +63,16 @@ module ActiveAgent
63
63
  # @return [Array<Usage>]
64
64
  attribute :usages, default: -> { [] }, writable: false
65
65
 
66
+ # @!attribute [r] streamed_finish_reason
67
+ # Why a streamed generation stopped, as the provider reported it.
68
+ #
69
+ # A streamed generation has no response body to read it from, so
70
+ # the provider records it from the stream. {#finish_reason} falls
71
+ # back to it.
72
+ #
73
+ # @return [String, nil]
74
+ attribute :streamed_finish_reason, writable: false
75
+
66
76
  # Initializes response with deep-duplicated attributes.
67
77
  #
68
78
  # Deep duplication prevents external modifications from affecting internal state.
@@ -180,14 +190,17 @@ module ActiveAgent
180
190
  # response.stop_reason #=> "stop" (alias)
181
191
  def finish_reason
182
192
  @finish_reason ||= begin
183
- return nil unless raw_response
193
+ return streamed_finish_reason unless raw_response
184
194
 
185
195
  if raw_response.is_a?(Hash)
186
196
  # OpenAI format: choices[0].finish_reason or choices[0].message.finish_reason
187
197
  raw_response.dig(:choices, 0, :finish_reason) ||
188
198
  raw_response.dig(:choices, 0, :message, :finish_reason) ||
189
199
  # Anthropic format: stop_reason
190
- raw_response[:stop_reason]
200
+ raw_response[:stop_reason] ||
201
+ # OpenAI Responses format: why it was cut short, else its status
202
+ raw_response.dig(:incomplete_details, :reason) ||
203
+ raw_response[:status]
191
204
  end
192
205
  end
193
206
  end