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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +407 -1
- data/README.md +3 -0
- data/lib/active_agent/base.rb +29 -4
- data/lib/active_agent/delegation/schema.rb +51 -13
- data/lib/active_agent/evals/comparison.rb +169 -0
- data/lib/active_agent/evals/publisher.rb +19 -12
- data/lib/active_agent/evals/report.rb +82 -66
- data/lib/active_agent/evals/report_html.rb +149 -12
- data/lib/active_agent/evals/report_markdown.rb +202 -0
- data/lib/active_agent/evals/runner.rb +7 -1
- data/lib/active_agent/evals/statistics.rb +26 -0
- data/lib/active_agent/evals.rb +3 -0
- data/lib/active_agent/providers/_base_provider.rb +2 -0
- data/lib/active_agent/providers/anthropic_provider.rb +4 -1
- data/lib/active_agent/providers/common/responses/base.rb +15 -2
- data/lib/active_agent/providers/common/usage.rb +4 -2
- data/lib/active_agent/providers/open_ai/chat_provider.rb +46 -0
- data/lib/active_agent/providers/open_ai/responses_provider.rb +40 -4
- data/lib/active_agent/providers/opper/_types.rb +16 -0
- data/lib/active_agent/providers/opper/options.rb +38 -0
- data/lib/active_agent/providers/opper_provider.rb +72 -0
- data/lib/active_agent/schema_generator.rb +146 -22
- data/lib/active_agent/version.rb +1 -1
- metadata +8 -2
|
@@ -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
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
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
|
|
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">></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
|
|
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('<', '<').gsub('>', '>')}" }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def html_text(text)
|
|
198
|
+
text.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">").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
|
data/lib/active_agent/evals.rb
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|