""" Generates a standalone, beautiful HTML report of the mismatch analysis. The HTML embeds Tailwind CSS via CDN and requires no server to view. """ from __future__ import annotations import json import math from datetime import datetime from pathlib import Path from utils.text_utils import format_timestamp _STATUS_CONFIG = { "OK": {"badge": "bg-emerald-500/20 text-emerald-300 border-emerald-500/30", "row": "hover:bg-slate-700/30", "dot": "#10b981"}, "MARGINAL": {"badge": "bg-amber-500/20 text-amber-300 border-amber-500/30", "row": "hover:bg-amber-900/10 bg-amber-900/5", "dot": "#f59e0b"}, "REVIEW": {"badge": "bg-red-500/20 text-red-300 border-red-500/30", "row": "hover:bg-red-900/10 bg-red-900/5", "dot": "#ef4444"}, "MISSING": {"badge": "bg-slate-500/20 text-slate-400 border-slate-500/30", "row": "hover:bg-slate-700/30 bg-slate-800/50", "dot": "#6b7280"}, } class ReportGenerator: def generate( self, results: list[dict], video_url: str, output_path: str, ) -> dict: """ Generate an HTML report and write it to `output_path`. Also returns structured data (for JSON API response). """ stats = self._compute_stats(results) html = self._render_html(results, stats, video_url) Path(output_path).write_text(html, encoding="utf-8") return { "stats": stats, "segments": results, "video_url": video_url, "generated_at": datetime.now().isoformat(), "report_path": output_path, } # ------------------------------------------------------------------ # Stats # ------------------------------------------------------------------ def _compute_stats(self, results: list[dict]) -> dict: total = len(results) ok = sum(1 for r in results if r["status"] == "OK") marginal = sum(1 for r in results if r["status"] == "MARGINAL") review = sum(1 for r in results if r["status"] == "REVIEW") missing = sum(1 for r in results if r["status"] == "MISSING") flagged = marginal + review + missing avg_score = ( sum(r["score"] for r in results if r["has_subtitle"]) / max(1, sum(1 for r in results if r["has_subtitle"])) ) return { "total": total, "ok": ok, "marginal": marginal, "review": review, "missing": missing, "flagged": flagged, "avg_score": round(avg_score, 4), "match_rate": round(ok / max(1, total) * 100, 1), } # ------------------------------------------------------------------ # HTML rendering # ------------------------------------------------------------------ def _render_html(self, results: list[dict], stats: dict, video_url: str) -> str: rows_html = "".join(self._render_row(r) for r in results) results_json = json.dumps(results, ensure_ascii=False) return f""" Subtitle Mismatch Report — PlanetRead
P
PlanetRead
Audio-Subtitle Mismatch Report
{datetime.now().strftime("%B %d, %Y %H:%M")}
{video_url}
{self._stat_card("Total Segments", str(stats['total']), "#6366f1", "M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z")} {self._stat_card("Match Rate", f"{stats['match_rate']}%", "#10b981", "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z")} {self._stat_card("Flagged for Review", str(stats['flagged']), "#ef4444", "M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z")} {self._stat_card("Avg Score", f"{stats['avg_score']:.2f}", "#f59e0b", "M13 10V3L4 14h7v7l9-11h-7z")}

Segment Breakdown

OK ({stats['ok']}) Marginal ({stats['marginal']}) Review ({stats['review']}) Missing ({stats['missing']})
Filter:
{rows_html}
Time Audio (Whisper) Subtitle Score Status
Generated by PlanetRead Audio-Subtitle Mismatch Detector Open Source — MIT License
""" def _stat_card(self, label: str, value: str, color: str, icon_path: str) -> str: return f"""
{value}
{label}
""" def _render_row(self, r: dict) -> str: cfg = _STATUS_CONFIG.get(r["status"], _STATUS_CONFIG["MISSING"]) score_pct = int(r["score"] * 100) bar_color = cfg["dot"] audio_display = r["audio_text"] or 'empty' sub_display = r["subtitle_text"] or 'not found' return f""" {r['timestamp_label']} {audio_display} {sub_display}
{r['score']:.2f}
{r['status']} """