#!/usr/bin/env python3 """ report_generator.py ā Comprehensive HTML + Markdown Report Generator for the Geng Skill Academic Fraud Detection Project. Generates professional, self-contained analysis reports from assessment JSON produced by the detection pipeline. Supports two output formats: - HTML (self-contained with embedded CSS/figures, printable) - Markdown (for GitHub/documentation, figures as relative paths) Usage: python3 report_generator.py --input assessment.json --figures figures/ --output report/ The input assessment.json is expected to have this structure: { "metadata": { "source_file", "timestamp", "tool_version", "columns", "rows", ... }, "overall_risk": { "score": 0-100, "level": "LOW|MEDIUM|HIGH|CRITICAL" }, "data_overview": { "columns": [...], "preview": [...], "statistics": {...} }, "modules": [ { "name": "...", "description": "...", "method": "...", "results": { ... }, "figures": ["fig1.png", ...], "risk_level": "LOW|MEDIUM|HIGH|CRITICAL", "p_value": ..., "test_statistic": ..., "evidence_summary": "..." }, ... ], "suspicious_points": [ { "row": ..., "column": "...", "value": ..., "reason": "...", "module": "..." }, ... ], "confidence": { "overall": ..., "intervals": {...}, "limitations": [...] }, "recommendations": [ { "priority": 1, "action": "...", "rationale": "..." }, ... ] } Author: BioMaster / Geng Skill Project License: MIT """ import argparse import base64 import json import os import sys from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- TOOL_VERSION = "1.0.0" TOOL_NAME = "Geng Skill Academic Data Integrity Analyzer" RISK_COLORS = { "LOW": "#28a745", # green "MEDIUM": "#ffc107", # yellow/amber "HIGH": "#fd7e14", # orange "CRITICAL": "#dc3545", # red } RISK_EMOJI = { "LOW": "š¢", "MEDIUM": "š”", "HIGH": "š ", "CRITICAL": "š“", } RISK_LABELS = { "LOW": "Low Risk", "MEDIUM": "Medium Risk", "HIGH": "High Risk", "CRITICAL": "Critical Risk", } METHODOLOGY_REFERENCES = { "benford": { "name": "Benford's Law (First-Digit Test)", "description": ( "Tests whether the distribution of leading digits in the dataset " "conforms to the logarithmic distribution predicted by Benford's Law. " "Fabricated data often shows uniform or biased digit distributions." ), "references": [ "Benford, F. (1938). The law of anomalous numbers. Proc. Amer. Phil. Soc., 78(4), 551-572.", "Nigrini, M.J. (2012). Benford's Law. Wiley.", ], }, "terminal_digit": { "name": "Terminal Digit Analysis", "description": ( "Examines the distribution of last digits in numeric data. " "Authentic measurements typically show uniform terminal digit distribution, " "while fabricated data often exhibits preference for certain digits (e.g., 0, 5)." ), "references": [ "Mosimann, J.E., Wiseman, C.V., & Edelman, R.E. (1995). Data fabrication. " "Chance, 8(2), 7-12.", ], }, "grim": { "name": "GRIM Test (Granularity-Related Inconsistency of Means)", "description": ( "Verifies whether reported means are mathematically possible given the " "reported sample size and measurement granularity. Impossible means indicate " "either reporting errors or data fabrication." ), "references": [ "Brown, N.J.L., & Heathers, J.A.J. (2017). The GRIM test. " "Social Psychological and Personality Science, 8(4), 363-369.", ], }, "sprite": { "name": "SPRITE (Sample Parameter Reconstruction via Iterative TEchniques)", "description": ( "Reconstructs possible raw data distributions consistent with reported " "summary statistics. Flags cases where no valid distribution exists." ), "references": [ "Heathers, J.A.J., & Brown, N.J.L. (2019). SPRITE. PeerJ Preprints.", ], }, "distribution": { "name": "Distribution Shape Analysis", "description": ( "Tests data against expected statistical distributions using " "Kolmogorov-Smirnov, Shapiro-Wilk, or Anderson-Darling tests. " "Fabricated data often shows abnormal distributional properties." ), "references": [ "Simonsohn, U. (2013). Just post it. Psychological Science, 24(10), 1875-1888.", ], }, "duplicates": { "name": "Duplicate/Near-Duplicate Detection", "description": ( "Identifies exact and near-duplicate values, rows, or patterns that occur " "more frequently than expected by chance." ), "references": [ "Bik, E.M., Casadevall, A., & Fang, F.C. (2016). The prevalence of " "inappropriate image duplication. mBio, 7(3), e00809-16.", ], }, "variance": { "name": "Variance Analysis (ANOVA / Levene's Test)", "description": ( "Examines whether variance patterns are consistent with genuine experimental " "data. Fabricated data often shows abnormally low or uniform variance." ), "references": [ "Carlisle, J.B. (2017). Data fabrication and other reasons for " "non-random sampling. Anaesthesia, 72(8), 944-952.", ], }, "correlation": { "name": "Correlation Structure Analysis", "description": ( "Checks whether inter-variable correlations are biologically/experimentally " "plausible. Fabricated data may show correlations that are too perfect or " "internally inconsistent." ), "references": [ "Simonsohn, U. (2014). Posterior-Hacking. Available at SSRN.", ], }, } DISCLAIMER_EN = """ **DISCLAIMER**: This report is generated by an automated statistical analysis tool and is intended for preliminary screening purposes ONLY. The results do NOT constitute proof of misconduct. Statistical anomalies can arise from legitimate methodological choices, measurement artifacts, or natural data properties. Any findings should be interpreted by qualified experts and investigated through proper institutional channels before any conclusions about research integrity are drawn. This tool should NEVER be used as the sole basis for accusations of fraud or misconduct. """.strip() DISCLAIMER_ZH = """ **å 蓣声ę**ļ¼ę¬ę„åē±čŖåØåē»č®”åęå·„å ·ēęļ¼ä» ēØäŗåę„ēę„ē®ēćåęē»ęäøęęå¦ęÆäøē«ÆēčÆę®ć ē»č®”å¼åøøåÆč½ęŗäŗåēēę¹ę³å¦éę©ćęµé误差ęę°ę®ēčŖē¶å±ę§ćä»»ä½åē°é½åŗē±å ·å¤čµč“Øēäøå®¶č§£čÆ»ļ¼ å¹¶éčæę£č§ēęŗęęø éčæč”č°ę„ļ¼ę¹åÆå¾åŗå ³äŗē ē©¶čÆäæ”ēē»č®ŗćę¬å·„å ·ē»äøåŗä½äøŗęę§ę¬ŗčÆęäøē«Æč”äøŗē åÆäøä¾ę®ć """.strip() # --------------------------------------------------------------------------- # HTML Template & CSS # --------------------------------------------------------------------------- HTML_CSS = """ :root { --primary: #2c3e50; --secondary: #34495e; --accent: #3498db; --bg: #ffffff; --bg-alt: #f8f9fa; --border: #dee2e6; --text: #212529; --text-muted: #6c757d; --success: #28a745; --warning: #ffc107; --danger: #dc3545; --orange: #fd7e14; } * { box-sizing: border-box; } body { font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: var(--text); background: var(--bg); margin: 0; padding: 0; } .container { max-width: 1100px; margin: 0 auto; padding: 2rem; } /* Header */ .report-header { border-bottom: 3px solid var(--primary); padding-bottom: 1.5rem; margin-bottom: 2rem; } .report-header h1 { font-size: 1.8rem; color: var(--primary); margin: 0 0 0.5rem 0; } .report-header .subtitle { font-size: 1rem; color: var(--text-muted); margin: 0; } /* Risk Badge */ .risk-badge { display: inline-block; padding: 0.4rem 1rem; border-radius: 4px; font-weight: 700; font-size: 0.9rem; color: #fff; text-transform: uppercase; letter-spacing: 0.5px; } .risk-badge.low { background: var(--success); } .risk-badge.medium { background: var(--warning); color: #212529; } .risk-badge.high { background: var(--orange); } .risk-badge.critical { background: var(--danger); } /* Score Meter */ .score-meter { width: 100%; height: 24px; background: #e9ecef; border-radius: 12px; overflow: hidden; margin: 0.5rem 0; } .score-meter .fill { height: 100%; border-radius: 12px; transition: width 0.5s; display: flex; align-items: center; justify-content: center; font-size: 0.75rem; font-weight: 700; color: #fff; } /* Sections */ .section { margin-bottom: 2.5rem; } .section h2 { font-size: 1.4rem; color: var(--primary); border-bottom: 2px solid var(--accent); padding-bottom: 0.5rem; margin-bottom: 1rem; } .section h3 { font-size: 1.1rem; color: var(--secondary); margin-top: 1.5rem; margin-bottom: 0.5rem; } /* Cards */ .card { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 1.2rem; margin-bottom: 1rem; box-shadow: 0 1px 3px rgba(0,0,0,0.04); } .card.risk-low { border-left: 4px solid var(--success); } .card.risk-medium { border-left: 4px solid var(--warning); } .card.risk-high { border-left: 4px solid var(--orange); } .card.risk-critical { border-left: 4px solid var(--danger); } .card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.8rem; } .card-header h3 { margin: 0; font-size: 1.05rem; } /* Tables */ table { width: 100%; border-collapse: collapse; margin: 1rem 0; font-size: 0.9rem; } th, td { padding: 0.6rem 0.8rem; text-align: left; border-bottom: 1px solid var(--border); } th { background: var(--primary); color: #fff; font-weight: 600; position: sticky; top: 0; } tr:nth-child(even) { background: var(--bg-alt); } tr:hover { background: #e8f4fd; } /* Figures */ .figure-container { text-align: center; margin: 1rem 0; } .figure-container img { max-width: 100%; height: auto; border: 1px solid var(--border); border-radius: 4px; } .figure-caption { font-size: 0.85rem; color: var(--text-muted); margin-top: 0.4rem; font-style: italic; } /* Stats Grid */ .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin: 1rem 0; } .stat-box { background: var(--bg-alt); border: 1px solid var(--border); border-radius: 6px; padding: 1rem; text-align: center; } .stat-box .stat-value { font-size: 1.6rem; font-weight: 700; color: var(--primary); } .stat-box .stat-label { font-size: 0.8rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; } /* Evidence */ .evidence-list { list-style: none; padding: 0; } .evidence-list li { padding: 0.4rem 0; padding-left: 1.5rem; position: relative; } .evidence-list li::before { content: 'ā¢'; position: absolute; left: 0.5rem; color: var(--accent); font-weight: 700; } /* Suspicious Points Table */ .suspicious-row { background: #fff3cd !important; } /* Disclaimer */ .disclaimer { background: #f8d7da; border: 1px solid #f5c6cb; border-radius: 6px; padding: 1.2rem; margin: 2rem 0; font-size: 0.9rem; } .disclaimer h3 { color: var(--danger); margin-top: 0; } /* Footer */ .report-footer { border-top: 2px solid var(--border); padding-top: 1rem; margin-top: 3rem; font-size: 0.8rem; color: var(--text-muted); display: flex; justify-content: space-between; flex-wrap: wrap; } /* Print Styles */ @media print { body { font-size: 10pt; } .container { max-width: 100%; padding: 0; } .card { break-inside: avoid; } .section { break-inside: avoid; } table { font-size: 8pt; } .report-header { border-bottom-width: 2px; } } /* Recommendations */ .recommendation { display: flex; align-items: flex-start; gap: 0.8rem; padding: 0.8rem; margin-bottom: 0.5rem; background: var(--bg-alt); border-radius: 6px; } .recommendation .priority-num { background: var(--accent); color: #fff; width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 0.85rem; flex-shrink: 0; } .recommendation .rec-content { flex: 1; } .recommendation .rec-action { font-weight: 600; margin-bottom: 0.2rem; } .recommendation .rec-rationale { font-size: 0.85rem; color: var(--text-muted); } /* Methodology */ .method-entry { margin-bottom: 1.2rem; padding-left: 1rem; border-left: 3px solid var(--accent); } .method-entry .method-name { font-weight: 700; margin-bottom: 0.3rem; } .method-entry .method-desc { font-size: 0.9rem; margin-bottom: 0.3rem; } .method-entry .method-ref { font-size: 0.8rem; color: var(--text-muted); font-style: italic; } """ # --------------------------------------------------------------------------- # Helper Functions # --------------------------------------------------------------------------- def load_assessment(path: str) -> Dict[str, Any]: """Load and validate the assessment JSON file. Args: path: Path to the assessment JSON file. Returns: Parsed assessment dictionary. Raises: FileNotFoundError: If the assessment file doesn't exist. json.JSONDecodeError: If the file is not valid JSON. ValueError: If required fields are missing. """ filepath = Path(path) if not filepath.exists(): raise FileNotFoundError(f"Assessment file not found: {path}") with open(filepath, "r", encoding="utf-8") as f: data = json.load(f) # Validate required top-level keys required = ["metadata", "overall_risk", "modules"] missing = [k for k in required if k not in data] if missing: raise ValueError(f"Assessment JSON missing required keys: {missing}") return data def encode_figure_base64(figure_path: str, figures_dir: str) -> Optional[str]: """Encode a figure file as base64 data URI for HTML embedding. Args: figure_path: Filename or relative path of the figure. figures_dir: Directory containing figures. Returns: Base64 data URI string, or None if file not found. """ full_path = Path(figures_dir) / figure_path if not full_path.exists(): return None suffix = full_path.suffix.lower() mime_map = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".svg": "image/svg+xml", ".gif": "image/gif", } mime_type = mime_map.get(suffix, "image/png") with open(full_path, "rb") as f: encoded = base64.b64encode(f.read()).decode("ascii") return f"data:{mime_type};base64,{encoded}" def risk_level_to_css_class(level: str) -> str: """Convert risk level string to CSS class name. Args: level: Risk level (LOW, MEDIUM, HIGH, CRITICAL). Returns: CSS class string. """ return level.lower() def format_p_value(p: Optional[float]) -> str: """Format a p-value for display with appropriate precision. Args: p: The p-value to format, or None. Returns: Formatted string representation. """ if p is None: return "N/A" if p < 0.001: return f"< 0.001 (p = {p:.2e})" elif p < 0.01: return f"{p:.4f}" elif p < 0.05: return f"{p:.3f}" else: return f"{p:.3f}" def score_to_color(score: float) -> str: """Map a 0-100 risk score to a gradient color. Args: score: Risk score (0-100). Returns: CSS color string. """ if score <= 25: return RISK_COLORS["LOW"] elif score <= 50: return RISK_COLORS["MEDIUM"] elif score <= 75: return RISK_COLORS["HIGH"] else: return RISK_COLORS["CRITICAL"] def score_to_level(score: float) -> str: """Map a 0-100 risk score to a risk level string. Args: score: Risk score (0-100). Returns: Risk level string. """ if score <= 25: return "LOW" elif score <= 50: return "MEDIUM" elif score <= 75: return "HIGH" else: return "CRITICAL" # --------------------------------------------------------------------------- # HTML Report Generator # --------------------------------------------------------------------------- class HTMLReportGenerator: """Generates a self-contained HTML report from assessment data. The report includes embedded CSS, base64-encoded figures, and is designed to be printable without external dependencies. Attributes: assessment: The assessment data dictionary. figures_dir: Path to the directory containing figure files. """ def __init__(self, assessment: Dict[str, Any], figures_dir: str): """Initialize the HTML report generator. Args: assessment: Parsed assessment dictionary. figures_dir: Path to directory containing figure image files. """ self.assessment = assessment self.figures_dir = figures_dir def generate(self) -> str: """Generate the complete HTML report. Returns: Complete HTML document as a string. """ parts = [ self._html_head(), '
', '