datasynth_eval/report/
html.rs

1//! HTML report generation.
2//!
3//! Generates human-readable HTML reports with charts and visualizations.
4
5use super::{EvaluationReport, IssueCategory, IssueSeverity, ReportGenerator};
6use crate::error::EvalResult;
7
8/// HTML report generator.
9pub struct HtmlReportGenerator {
10    /// Include inline CSS.
11    include_css: bool,
12    /// Include charts.
13    include_charts: bool,
14}
15
16impl HtmlReportGenerator {
17    /// Create a new generator.
18    pub fn new() -> Self {
19        Self {
20            include_css: true,
21            include_charts: true,
22        }
23    }
24
25    /// Set whether to include CSS.
26    pub fn with_css(mut self, include: bool) -> Self {
27        self.include_css = include;
28        self
29    }
30
31    /// Set whether to include charts.
32    pub fn with_charts(mut self, include: bool) -> Self {
33        self.include_charts = include;
34        self
35    }
36
37    /// Generate CSS styles.
38    fn generate_css(&self) -> String {
39        r#"
40        <style>
41            :root {
42                --pass-color: #22c55e;
43                --fail-color: #ef4444;
44                --warning-color: #f59e0b;
45                --info-color: #3b82f6;
46                --bg-color: #f8fafc;
47                --card-bg: #ffffff;
48                --text-color: #1e293b;
49                --border-color: #e2e8f0;
50            }
51            body {
52                font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
53                background: var(--bg-color);
54                color: var(--text-color);
55                line-height: 1.6;
56                margin: 0;
57                padding: 20px;
58            }
59            .container {
60                max-width: 1200px;
61                margin: 0 auto;
62            }
63            h1, h2, h3 {
64                margin-top: 0;
65            }
66            .header {
67                text-align: center;
68                margin-bottom: 30px;
69            }
70            .status-badge {
71                display: inline-block;
72                padding: 8px 24px;
73                border-radius: 20px;
74                font-weight: bold;
75                font-size: 1.2em;
76            }
77            .status-pass {
78                background: var(--pass-color);
79                color: white;
80            }
81            .status-fail {
82                background: var(--fail-color);
83                color: white;
84            }
85            .card {
86                background: var(--card-bg);
87                border-radius: 8px;
88                box-shadow: 0 1px 3px rgba(0,0,0,0.1);
89                padding: 20px;
90                margin-bottom: 20px;
91            }
92            .card-title {
93                font-size: 1.2em;
94                font-weight: 600;
95                margin-bottom: 15px;
96                padding-bottom: 10px;
97                border-bottom: 1px solid var(--border-color);
98            }
99            .metric-grid {
100                display: grid;
101                grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
102                gap: 15px;
103            }
104            .metric {
105                padding: 15px;
106                background: var(--bg-color);
107                border-radius: 6px;
108            }
109            .metric-label {
110                font-size: 0.85em;
111                color: #64748b;
112                margin-bottom: 5px;
113            }
114            .metric-value {
115                font-size: 1.5em;
116                font-weight: 600;
117            }
118            .metric-pass { color: var(--pass-color); }
119            .metric-fail { color: var(--fail-color); }
120            .metric-warning { color: var(--warning-color); }
121            .issues-list {
122                list-style: none;
123                padding: 0;
124                margin: 0;
125            }
126            .issue-item {
127                padding: 12px 15px;
128                border-left: 4px solid;
129                margin-bottom: 10px;
130                background: var(--bg-color);
131                border-radius: 0 6px 6px 0;
132            }
133            .issue-critical { border-color: var(--fail-color); }
134            .issue-warning { border-color: var(--warning-color); }
135            .issue-info { border-color: var(--info-color); }
136            .issue-category {
137                font-size: 0.75em;
138                text-transform: uppercase;
139                color: #64748b;
140                margin-bottom: 5px;
141            }
142            table {
143                width: 100%;
144                border-collapse: collapse;
145            }
146            th, td {
147                padding: 10px;
148                text-align: left;
149                border-bottom: 1px solid var(--border-color);
150            }
151            th {
152                background: var(--bg-color);
153                font-weight: 600;
154            }
155            .score-bar {
156                height: 8px;
157                background: var(--border-color);
158                border-radius: 4px;
159                overflow: hidden;
160            }
161            .score-fill {
162                height: 100%;
163                border-radius: 4px;
164            }
165            .score-good { background: var(--pass-color); }
166            .score-medium { background: var(--warning-color); }
167            .score-bad { background: var(--fail-color); }
168            .metadata {
169                font-size: 0.85em;
170                color: #64748b;
171            }
172        </style>
173        "#
174        .to_string()
175    }
176
177    /// Generate summary section.
178    fn generate_summary(&self, report: &EvaluationReport) -> String {
179        let status_class = if report.passes {
180            "status-pass"
181        } else {
182            "status-fail"
183        };
184        let status_text = if report.passes { "PASSED" } else { "FAILED" };
185
186        format!(
187            r#"
188            <div class="header">
189                <h1>Synthetic Data Evaluation Report</h1>
190                <div class="status-badge {status_class}">{status_text}</div>
191                <p class="metadata">
192                    Generated: {} | Records: {} | Duration: {}ms
193                </p>
194            </div>
195
196            <div class="card">
197                <div class="card-title">Overall Score</div>
198                <div class="metric-grid">
199                    <div class="metric">
200                        <div class="metric-label">Overall Score</div>
201                        <div class="metric-value {}">{:.1}%</div>
202                        <div class="score-bar">
203                            <div class="score-fill {}" style="width: {:.1}%"></div>
204                        </div>
205                    </div>
206                    <div class="metric">
207                        <div class="metric-label">Issues Found</div>
208                        <div class="metric-value">{}</div>
209                    </div>
210                    <div class="metric">
211                        <div class="metric-label">Critical Issues</div>
212                        <div class="metric-value {}">{}</div>
213                    </div>
214                </div>
215            </div>
216            "#,
217            report.metadata.generated_at.format("%Y-%m-%d %H:%M:%S UTC"),
218            report.metadata.records_evaluated,
219            report.metadata.duration_ms,
220            self.score_class(report.overall_score),
221            report.overall_score * 100.0,
222            self.score_bar_class(report.overall_score),
223            report.overall_score * 100.0,
224            report.all_issues.len(),
225            if report.critical_issues().is_empty() {
226                "metric-pass"
227            } else {
228                "metric-fail"
229            },
230            report.critical_issues().len()
231        )
232    }
233
234    /// Generate statistical section.
235    fn generate_statistical_section(&self, report: &EvaluationReport) -> String {
236        let Some(ref stat) = report.statistical else {
237            return String::new();
238        };
239
240        let mut metrics_html = String::new();
241
242        if let Some(ref benford) = stat.benford {
243            metrics_html.push_str(&format!(
244                r#"
245                <div class="metric">
246                    <div class="metric-label">Benford's Law p-value</div>
247                    <div class="metric-value {}">{:.4}</div>
248                </div>
249                <div class="metric">
250                    <div class="metric-label">Benford MAD</div>
251                    <div class="metric-value {}">{:.4}</div>
252                </div>
253                <div class="metric">
254                    <div class="metric-label">Conformity Level</div>
255                    <div class="metric-value">{:?}</div>
256                </div>
257                "#,
258                if benford.passes {
259                    "metric-pass"
260                } else {
261                    "metric-fail"
262                },
263                benford.p_value,
264                if benford.mad <= 0.015 {
265                    "metric-pass"
266                } else {
267                    "metric-warning"
268                },
269                benford.mad,
270                benford.conformity
271            ));
272        }
273
274        if let Some(ref temporal) = stat.temporal {
275            metrics_html.push_str(&format!(
276                r#"
277                <div class="metric">
278                    <div class="metric-label">Temporal Correlation</div>
279                    <div class="metric-value {}">{:.2}</div>
280                </div>
281                <div class="metric">
282                    <div class="metric-label">Weekend Ratio</div>
283                    <div class="metric-value">{:.1}%</div>
284                </div>
285                "#,
286                if temporal.pattern_correlation >= 0.8 {
287                    "metric-pass"
288                } else {
289                    "metric-warning"
290                },
291                temporal.pattern_correlation,
292                temporal.weekend_ratio * 100.0
293            ));
294        }
295
296        format!(
297            r#"
298            <div class="card">
299                <div class="card-title">Statistical Quality</div>
300                <div class="metric-grid">
301                    {metrics_html}
302                </div>
303            </div>
304            "#
305        )
306    }
307
308    /// Generate coherence section.
309    fn generate_coherence_section(&self, report: &EvaluationReport) -> String {
310        let Some(ref coh) = report.coherence else {
311            return String::new();
312        };
313
314        let mut metrics_html = String::new();
315
316        if let Some(ref balance) = coh.balance {
317            metrics_html.push_str(&format!(
318                r#"
319                <div class="metric">
320                    <div class="metric-label">Balance Sheet Equation</div>
321                    <div class="metric-value {}">{}</div>
322                </div>
323                <div class="metric">
324                    <div class="metric-label">Periods Evaluated</div>
325                    <div class="metric-value">{}</div>
326                </div>
327                "#,
328                if balance.equation_balanced {
329                    "metric-pass"
330                } else {
331                    "metric-fail"
332                },
333                if balance.equation_balanced {
334                    "Balanced"
335                } else {
336                    "Imbalanced"
337                },
338                balance.periods_evaluated
339            ));
340        }
341
342        if let Some(ref sub) = coh.subledger {
343            metrics_html.push_str(&format!(
344                r#"
345                <div class="metric">
346                    <div class="metric-label">Subledger Reconciliation</div>
347                    <div class="metric-value {}">{:.1}%</div>
348                </div>
349                "#,
350                if sub.completeness_score >= 0.99 {
351                    "metric-pass"
352                } else {
353                    "metric-fail"
354                },
355                sub.completeness_score * 100.0
356            ));
357        }
358
359        if let Some(ref ic) = coh.intercompany {
360            metrics_html.push_str(&format!(
361                r#"
362                <div class="metric">
363                    <div class="metric-label">IC Match Rate</div>
364                    <div class="metric-value {}">{:.1}%</div>
365                </div>
366                "#,
367                if ic.match_rate >= 0.95 {
368                    "metric-pass"
369                } else {
370                    "metric-warning"
371                },
372                ic.match_rate * 100.0
373            ));
374        }
375
376        format!(
377            r#"
378            <div class="card">
379                <div class="card-title">Semantic Coherence</div>
380                <div class="metric-grid">
381                    {metrics_html}
382                </div>
383            </div>
384            "#
385        )
386    }
387
388    /// Generate issues section.
389    fn generate_issues_section(&self, report: &EvaluationReport) -> String {
390        if report.all_issues.is_empty() {
391            return r#"
392            <div class="card">
393                <div class="card-title">Issues</div>
394                <p>No issues found.</p>
395            </div>
396            "#
397            .to_string();
398        }
399
400        let mut issues_html = String::new();
401        for issue in &report.all_issues {
402            let severity_class = match issue.severity {
403                IssueSeverity::Critical => "issue-critical",
404                IssueSeverity::Warning => "issue-warning",
405                IssueSeverity::Info => "issue-info",
406            };
407            let category_name = match issue.category {
408                IssueCategory::Statistical => "Statistical",
409                IssueCategory::Coherence => "Coherence",
410                IssueCategory::Quality => "Quality",
411                IssueCategory::MLReadiness => "ML Readiness",
412            };
413
414            issues_html.push_str(&format!(
415                r#"
416                <li class="issue-item {severity_class}">
417                    <div class="issue-category">{category_name}</div>
418                    <div>{}</div>
419                </li>
420                "#,
421                issue.description
422            ));
423        }
424
425        format!(
426            r#"
427            <div class="card">
428                <div class="card-title">Issues ({} found)</div>
429                <ul class="issues-list">
430                    {issues_html}
431                </ul>
432            </div>
433            "#,
434            report.all_issues.len()
435        )
436    }
437
438    /// Get CSS class for score value.
439    fn score_class(&self, score: f64) -> &'static str {
440        if score >= 0.9 {
441            "metric-pass"
442        } else if score >= 0.7 {
443            "metric-warning"
444        } else {
445            "metric-fail"
446        }
447    }
448
449    /// Get CSS class for score bar.
450    fn score_bar_class(&self, score: f64) -> &'static str {
451        if score >= 0.9 {
452            "score-good"
453        } else if score >= 0.7 {
454            "score-medium"
455        } else {
456            "score-bad"
457        }
458    }
459}
460
461impl Default for HtmlReportGenerator {
462    fn default() -> Self {
463        Self::new()
464    }
465}
466
467impl ReportGenerator for HtmlReportGenerator {
468    fn generate(&self, report: &EvaluationReport) -> EvalResult<String> {
469        let css = if self.include_css {
470            self.generate_css()
471        } else {
472            String::new()
473        };
474
475        let summary = self.generate_summary(report);
476        let statistical = self.generate_statistical_section(report);
477        let coherence = self.generate_coherence_section(report);
478        let issues = self.generate_issues_section(report);
479
480        let html = format!(
481            r#"<!DOCTYPE html>
482<html lang="en">
483<head>
484    <meta charset="UTF-8">
485    <meta name="viewport" content="width=device-width, initial-scale=1.0">
486    <title>Evaluation Report - {}</title>
487    {css}
488</head>
489<body>
490    <div class="container">
491        {summary}
492        {statistical}
493        {coherence}
494        {issues}
495    </div>
496</body>
497</html>"#,
498            report.metadata.generated_at.format("%Y-%m-%d")
499        );
500
501        Ok(html)
502    }
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508    use crate::report::ReportMetadata;
509    use chrono::Utc;
510
511    #[test]
512    fn test_html_generation() {
513        let metadata = ReportMetadata {
514            generated_at: Utc::now(),
515            version: "1.0.0".to_string(),
516            data_source: "test".to_string(),
517            thresholds_name: "default".to_string(),
518            records_evaluated: 1000,
519            duration_ms: 500,
520        };
521
522        let report = EvaluationReport::new(metadata, None, None, None, None);
523        let generator = HtmlReportGenerator::new();
524        let html = generator.generate(&report).unwrap();
525
526        assert!(html.contains("<!DOCTYPE html>"));
527        assert!(html.contains("PASSED"));
528    }
529}