datasynth_eval/report/
html.rs1use super::{EvaluationReport, IssueCategory, IssueSeverity, ReportGenerator};
6use crate::error::EvalResult;
7
8pub struct HtmlReportGenerator {
10 include_css: bool,
12 include_charts: bool,
14}
15
16impl HtmlReportGenerator {
17 pub fn new() -> Self {
19 Self {
20 include_css: true,
21 include_charts: true,
22 }
23 }
24
25 pub fn with_css(mut self, include: bool) -> Self {
27 self.include_css = include;
28 self
29 }
30
31 pub fn with_charts(mut self, include: bool) -> Self {
33 self.include_charts = include;
34 self
35 }
36
37 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 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 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 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 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 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 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}