submatch-backend / modules /report_generator.py
Janesh's picture
feat: SubMatch backend v2.0 — faster-whisper, Tesseract OCR, FastAPI
b165696
Raw
History Blame Contribute Delete
18.5 kB
"""
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"""<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Subtitle Mismatch Report — PlanetRead</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {{
darkMode: 'class',
theme: {{
extend: {{
fontFamily: {{
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
}},
}},
}},
}};
</script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<style>
body {{ font-family: 'Inter', sans-serif; }}
.score-bar {{ transition: width 0.6s cubic-bezier(0.4,0,0.2,1); }}
.fade-in {{ animation: fadeIn 0.3s ease; }}
@keyframes fadeIn {{ from {{ opacity:0; transform:translateY(4px); }} to {{ opacity:1; transform:translateY(0); }} }}
.hindi-text {{ font-size: 1rem; line-height: 1.6; }}
tr {{ transition: background 0.15s ease; }}
::-webkit-scrollbar {{ width:6px; height:6px; }}
::-webkit-scrollbar-track {{ background:#1e293b; }}
::-webkit-scrollbar-thumb {{ background:#475569; border-radius:3px; }}
</style>
</head>
<body class="bg-slate-900 text-slate-100 min-h-screen">
<!-- Header -->
<header class="border-b border-slate-700/60 bg-slate-900/95 backdrop-blur-sm sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-8 h-8 bg-gradient-to-br from-violet-500 to-indigo-600 rounded-lg flex items-center justify-center text-white font-bold text-sm">P</div>
<div>
<div class="font-semibold text-slate-100">PlanetRead</div>
<div class="text-xs text-slate-400">Audio-Subtitle Mismatch Report</div>
</div>
</div>
<div class="flex items-center gap-3">
<span class="text-xs text-slate-500">{datetime.now().strftime("%B %d, %Y %H:%M")}</span>
<button onclick="downloadReport()" class="flex items-center gap-2 px-3 py-1.5 bg-violet-600 hover:bg-violet-500 text-white text-xs font-medium rounded-lg transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
Download
</button>
</div>
</div>
</header>
<main class="max-w-7xl mx-auto px-6 py-8">
<!-- Video URL badge -->
<div class="mb-6 flex items-center gap-2 text-sm text-slate-400">
<svg class="w-4 h-4 text-red-400 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M19.59 6.69a4.83 4.83 0 01-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 01-2.88 2.5 2.89 2.89 0 01-2.89-2.89 2.89 2.89 0 012.89-2.89c.28 0 .54.04.79.1V9.01a6.33 6.33 0 00-.79-.05 6.34 6.34 0 00-6.34 6.34 6.34 6.34 0 006.34 6.34 6.34 6.34 0 006.33-6.34V9.35a8.16 8.16 0 004.77 1.52V7.42a4.85 4.85 0 01-1-.73z"/></svg>
<span class="truncate max-w-xl font-mono text-xs">{video_url}</span>
</div>
<!-- Stats cards -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
{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")}
</div>
<!-- Score breakdown bar -->
<div class="bg-slate-800/60 rounded-xl border border-slate-700/50 p-5 mb-6">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-slate-300">Segment Breakdown</h3>
<div class="flex items-center gap-4 text-xs text-slate-400">
<span class="flex items-center gap-1.5"><span class="w-2 h-2 rounded-full bg-emerald-500 inline-block"></span>OK ({stats['ok']})</span>
<span class="flex items-center gap-1.5"><span class="w-2 h-2 rounded-full bg-amber-500 inline-block"></span>Marginal ({stats['marginal']})</span>
<span class="flex items-center gap-1.5"><span class="w-2 h-2 rounded-full bg-red-500 inline-block"></span>Review ({stats['review']})</span>
<span class="flex items-center gap-1.5"><span class="w-2 h-2 rounded-full bg-slate-500 inline-block"></span>Missing ({stats['missing']})</span>
</div>
</div>
<div class="h-3 bg-slate-700 rounded-full overflow-hidden flex">
<div class="bg-emerald-500 h-full score-bar" style="width:{stats['ok']/max(1,stats['total'])*100:.1f}%"></div>
<div class="bg-amber-500 h-full score-bar" style="width:{stats['marginal']/max(1,stats['total'])*100:.1f}%"></div>
<div class="bg-red-500 h-full score-bar" style="width:{stats['review']/max(1,stats['total'])*100:.1f}%"></div>
<div class="bg-slate-500 h-full score-bar" style="width:{stats['missing']/max(1,stats['total'])*100:.1f}%"></div>
</div>
</div>
<!-- Filters -->
<div class="flex flex-wrap items-center gap-3 mb-4">
<span class="text-sm text-slate-400 font-medium">Filter:</span>
<button onclick="filterTable('all')" id="btn-all" class="filter-btn active px-3 py-1 rounded-lg text-xs font-medium border border-violet-500 bg-violet-500/20 text-violet-300 transition-all">All ({stats['total']})</button>
<button onclick="filterTable('REVIEW')" id="btn-REVIEW" class="filter-btn px-3 py-1 rounded-lg text-xs font-medium border border-slate-600 text-slate-400 hover:border-red-500/50 hover:text-red-300 transition-all">Review Only ({stats['review']})</button>
<button onclick="filterTable('MARGINAL')" id="btn-MARGINAL" class="filter-btn px-3 py-1 rounded-lg text-xs font-medium border border-slate-600 text-slate-400 hover:border-amber-500/50 hover:text-amber-300 transition-all">Marginal ({stats['marginal']})</button>
<button onclick="filterTable('OK')" id="btn-OK" class="filter-btn px-3 py-1 rounded-lg text-xs font-medium border border-slate-600 text-slate-400 hover:border-emerald-500/50 hover:text-emerald-300 transition-all">OK ({stats['ok']})</button>
<button onclick="filterTable('MISSING')" id="btn-MISSING" class="filter-btn px-3 py-1 rounded-lg text-xs font-medium border border-slate-600 text-slate-400 hover:border-slate-400/50 transition-all">Missing ({stats['missing']})</button>
<div class="ml-auto">
<input id="search-input" oninput="searchTable(this.value)" type="text" placeholder="Search text..." class="px-3 py-1.5 bg-slate-800 border border-slate-600 rounded-lg text-xs text-slate-300 placeholder-slate-500 focus:outline-none focus:border-violet-500 w-48" />
</div>
</div>
<!-- Table -->
<div class="rounded-xl border border-slate-700/50 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm" id="results-table">
<thead>
<tr class="bg-slate-800/80 border-b border-slate-700/50">
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider w-24">Time</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">Audio (Whisper)</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">Subtitle</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider w-40">Score</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider w-28">Status</th>
</tr>
</thead>
<tbody id="table-body" class="divide-y divide-slate-700/30">
{rows_html}
</tbody>
</table>
</div>
</div>
<!-- Empty state (hidden by default) -->
<div id="empty-state" class="hidden text-center py-16 text-slate-500">
<svg class="w-12 h-12 mx-auto mb-4 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
<p class="font-medium">No segments match this filter</p>
</div>
<!-- Footer -->
<div class="mt-8 pt-6 border-t border-slate-700/40 flex items-center justify-between text-xs text-slate-500">
<span>Generated by <strong class="text-slate-400">PlanetRead Audio-Subtitle Mismatch Detector</strong></span>
<span>Open Source — MIT License</span>
</div>
</main>
<script>
const allData = {results_json};
let currentFilter = 'all';
let currentSearch = '';
function filterTable(status) {{
currentFilter = status;
document.querySelectorAll('.filter-btn').forEach(b => {{
b.classList.remove('active', 'border-violet-500', 'bg-violet-500/20', 'text-violet-300');
b.classList.add('border-slate-600', 'text-slate-400');
}});
const activeBtn = document.getElementById('btn-' + status);
if (activeBtn) {{
activeBtn.classList.add('active', 'border-violet-500', 'bg-violet-500/20', 'text-violet-300');
activeBtn.classList.remove('border-slate-600', 'text-slate-400');
}}
renderTable();
}}
function searchTable(val) {{
currentSearch = val.toLowerCase();
renderTable();
}}
function renderTable() {{
const tbody = document.getElementById('table-body');
const emptyState = document.getElementById('empty-state');
let filtered = allData;
if (currentFilter !== 'all') {{
filtered = filtered.filter(r => r.status === currentFilter);
}}
if (currentSearch) {{
filtered = filtered.filter(r =>
(r.audio_text || '').toLowerCase().includes(currentSearch) ||
(r.subtitle_text || '').toLowerCase().includes(currentSearch)
);
}}
if (filtered.length === 0) {{
tbody.innerHTML = '';
emptyState.classList.remove('hidden');
}} else {{
emptyState.classList.add('hidden');
tbody.innerHTML = filtered.map(r => buildRow(r)).join('');
}}
}}
function buildRow(r) {{
const statusColors = {{
'OK': 'bg-emerald-500/20 text-emerald-300 border-emerald-500/30',
'MARGINAL': 'bg-amber-500/20 text-amber-300 border-amber-500/30',
'REVIEW': 'bg-red-500/20 text-red-300 border-red-500/30',
'MISSING': 'bg-slate-500/20 text-slate-400 border-slate-500/30',
}};
const rowColors = {{
'OK': '',
'MARGINAL': 'bg-amber-900/5',
'REVIEW': 'bg-red-900/5',
'MISSING': 'bg-slate-800/50',
}};
const barColors = {{
'OK': '#10b981', 'MARGINAL': '#f59e0b', 'REVIEW': '#ef4444', 'MISSING': '#6b7280'
}};
const sc = (r.score * 100).toFixed(0);
return `
<tr class="border-b border-slate-700/20 ${{rowColors[r.status] || ''}} hover:bg-slate-700/20 transition-colors fade-in">
<td class="px-4 py-3 font-mono text-xs text-slate-400 whitespace-nowrap">${{r.timestamp_label}}</td>
<td class="px-4 py-3 hindi-text text-slate-200 max-w-xs">${{r.audio_text || '<span class="text-slate-500 italic text-xs">empty</span>'}}</td>
<td class="px-4 py-3 hindi-text text-slate-300 max-w-xs">${{r.subtitle_text || '<span class="text-slate-500 italic text-xs">not found</span>'}}</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<div class="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden w-16">
<div class="h-full rounded-full" style="width:${{sc}}%;background:${{barColors[r.status]}}"></div>
</div>
<span class="text-xs font-mono text-slate-300 w-8">${{r.score.toFixed(2)}}</span>
</div>
</td>
<td class="px-4 py-3">
<span class="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium border ${{statusColors[r.status] || ''}}">${{r.status}}</span>
</td>
</tr>`;
}}
function downloadReport() {{
const blob = new Blob([document.documentElement.outerHTML], {{type: 'text/html'}});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'mismatch-report.html';
a.click();
}}
</script>
</body>
</html>"""
def _stat_card(self, label: str, value: str, color: str, icon_path: str) -> str:
return f"""
<div class="bg-slate-800/60 rounded-xl border border-slate-700/50 p-5 flex items-start gap-4">
<div class="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0" style="background:{color}22">
<svg class="w-5 h-5" style="color:{color}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{icon_path}" />
</svg>
</div>
<div>
<div class="text-2xl font-bold text-slate-100">{value}</div>
<div class="text-xs text-slate-400 mt-0.5">{label}</div>
</div>
</div>"""
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 '<span class="text-slate-500 italic text-xs">empty</span>'
sub_display = r["subtitle_text"] or '<span class="text-slate-500 italic text-xs">not found</span>'
return f"""
<tr class="border-b border-slate-700/20 {cfg['row']} transition-colors">
<td class="px-4 py-3 font-mono text-xs text-slate-400 whitespace-nowrap">{r['timestamp_label']}</td>
<td class="px-4 py-3 hindi-text text-slate-200 max-w-xs">{audio_display}</td>
<td class="px-4 py-3 hindi-text text-slate-300 max-w-xs">{sub_display}</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<div class="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden w-16">
<div class="h-full rounded-full score-bar" style="width:{score_pct}%;background:{bar_color}"></div>
</div>
<span class="text-xs font-mono text-slate-300 w-8">{r['score']:.2f}</span>
</div>
</td>
<td class="px-4 py-3">
<span class="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium border {cfg['badge']}">{r['status']}</span>
</td>
</tr>"""