echo_agent 0.2.0

Production-grade AI Agent framework for Rust — ReAct engine, multi-agent, memory, streaming, MCP, IM channels, workflows
Documentation
//! HTML eval report generator — produces interactive evaluation reports.
//!
//! Inspired by `skill-creator/eval-viewer/`. Generates a self-contained HTML
//! file with pass/fail matrix, score distributions, and per-case details.

use crate::eval::EvalReport;

/// Generate a self-contained HTML report from an EvalReport.
pub fn generate_html(report: &EvalReport, title: &str) -> String {
    let min_score = report
        .results
        .iter()
        .map(|r| r.score)
        .fold(f64::INFINITY, f64::min);
    let max_score = report
        .results
        .iter()
        .map(|r| r.score)
        .fold(f64::NEG_INFINITY, f64::max);

    let rows: String = report
        .results
        .iter()
        .map(|r| {
            let status = if r.success { "PASS" } else { "FAIL" };
            let color = if r.success { "#22c55e" } else { "#ef4444" };
            let css_class = if r.success { "pass" } else { "fail" };
            format!(
                "<tr class='{css_class}' style='border-bottom:1px solid #eee'>\
                 <td style='color:{color};font-weight:bold'>{status}</td>\
                 <td>{}</td>\
                 <td style='text-align:right'>{:.2}</td>\
                 <td style='text-align:right'>{}</td>\
                 <td style='text-align:right'>{}</td>\
                 <td style='text-align:right'>{}ms</td>\
                 <td style='font-size:12px;color:#666'>{}</td>\
                 </tr>",
                r.case_id,
                r.score,
                r.tool_calls,
                r.file_changes,
                r.duration_ms,
                r.violations.first().unwrap_or(&String::new())
            )
        })
        .collect::<Vec<_>>()
        .join("\n");

    format!(
        r#"<!DOCTYPE html><html><head><meta charset="UTF-8">
<title>{title}</title>
<style>
 body {{ font-family: system-ui, sans-serif; max-width: 960px; margin: 2rem auto; padding: 0 1rem; }}
 h1 {{ color: #1e293b; }} .summary {{ display: flex; gap: 1rem; flex-wrap: wrap; margin: 1rem 0; }}
 .card {{ background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 1rem; min-width: 120px; text-align: center; }}
 .card .value {{ font-size: 1.5rem; font-weight: bold; color: #1e293b; }}
 .card .label {{ font-size: 0.8rem; color: #64748b; }}
 table {{ width: 100%; border-collapse: collapse; margin-top: 1rem; }}
 th {{ text-align: left; padding: 8px; background: #f1f5f9; }}
 td {{ padding: 8px; }}
 .bar {{ display: inline-block; height: 12px; background: #3b82f6; border-radius: 2px; }}
 .filters {{ margin: 1rem 0; }}
 .filters button {{ padding: 6px 16px; margin-right: 8px; border: 1px solid #e2e8f0; border-radius: 4px; background: #fff; cursor: pointer; }}
 .filters button.active {{ background: #3b82f6; color: #fff; border-color: #3b82f6; }}
 tr.hidden {{ display: none; }}
</style></head><body>
<h1>{title}</h1>
<div class="summary">
 <div class="card"><div class="value">{passed}/{total}</div><div class="label">Passed</div></div>
 <div class="card"><div class="value">{avg_score:.2}</div><div class="label">Avg Score</div></div>
 <div class="card"><div class="value">{min_score:.2} / {max_score:.2}</div><div class="label">Min / Max</div></div>
 <div class="card"><div class="value">{std_dev:.3}</div><div class="label">Std Dev</div></div>
 <div class="card"><div class="value">{total_tool_calls}</div><div class="label">Tool Calls</div></div>
 <div class="card"><div class="value">{total_tokens_in}+{total_tokens_out}</div><div class="label">Tokens I/O</div></div>
</div>
<div class="filters">
 <button class="active" onclick="filter('all')">All ({total})</button>
 <button onclick="filter('pass')">Passed ({passed})</button>
 <button onclick="filter('fail')">Failed ({failed})</button>
</div>
<table>
<tr><th>Status</th><th>Case</th><th>Score</th><th>Tools</th><th>Files</th><th>Time</th><th>Violations</th></tr>
{rows}
</table>
<script>
function filter(type) {{
  document.querySelectorAll('.filters button').forEach(b => b.classList.remove('active'));
  event.target.classList.add('active');
  document.querySelectorAll('tbody tr, table tr:not(:first-child)').forEach(row => {{
    if (type === 'all') row.classList.remove('hidden');
    else if (type === 'pass') row.classList.toggle('hidden', !row.classList.contains('pass'));
    else if (type === 'fail') row.classList.toggle('hidden', !row.classList.contains('fail'));
  }});
}}
</script>
<p style="margin-top:2rem;font-size:12px;color:#94a3b8">Generated by echo-agent eval framework</p>
</body></html>"#,
        title = title,
        passed = report.passed,
        failed = report.total.saturating_sub(report.passed),
        total = report.total,
        avg_score = report.avg_score,
        min_score = if min_score.is_infinite() {
            0.0
        } else {
            min_score
        },
        max_score = if max_score.is_infinite() {
            0.0
        } else {
            max_score
        },
        std_dev = report.std_dev,
        total_tool_calls = report.total_tool_calls,
        total_tokens_in = report.total_tokens_in,
        total_tokens_out = report.total_tokens_out,
        rows = rows,
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::eval::{EvalReport, EvalResult};

    #[test]
    fn test_generate_html() {
        let result = EvalResult::new("test1", true).with_metric("accuracy", 0.95, "good");
        let mut r = result;
        r.tool_calls = 3;
        r.file_changes = 1;
        r.duration_ms = 1200;
        let report = EvalReport::new(vec![r]);
        let html = generate_html(&report, "Test Report");
        assert!(html.contains("Test Report"));
        assert!(html.contains("PASS"));
        assert!(html.contains("1/1"));
    }
}