Add internal-meetings pipeline and post-hoc speaker tools
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Job output — Recap Relay</title>
|
||||
<!--
|
||||
Stand-alone render of a stored job's transcript + analysis JSON.
|
||||
Loaded by the operator's dashboard "View" link on a Jobs row.
|
||||
|
||||
Visual style: mirror of Recap's results panel (two-pane —
|
||||
topic list on the left, transcript on the right, click a topic
|
||||
to jump to its timestamp range in the transcript). Sourced from
|
||||
Recap's index.html .chunk + .transcript-line styling so changes
|
||||
there stay aesthetically aligned here.
|
||||
|
||||
Data source: GET /admin/job-output/:id returns
|
||||
{
|
||||
job_id, batch_id, source, saved_at,
|
||||
transcript: "[MM:SS] line\n[MM:SS] line...",
|
||||
analysis: { sections: [{ title, summary, startIndex, endIndex }] } | null
|
||||
analysis_raw_text: string | null // when JSON-parse failed
|
||||
meta: { title, media_url, audio_seconds, ... }
|
||||
}
|
||||
-->
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0e1a;
|
||||
--panel: #111827;
|
||||
--panel-2: #1e293b;
|
||||
--line: #1e293b;
|
||||
--line-2: #334155;
|
||||
--fg: #e2e8f0;
|
||||
--fg-dim: #94a3b8;
|
||||
--fg-faint: #64748b;
|
||||
--accent: #818cf8;
|
||||
--accent-soft: #a5b4fc;
|
||||
--good: #4ade80;
|
||||
--bad: #fca5a5;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; padding: 0;
|
||||
background: var(--bg); color: var(--fg);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
font-size: 13px; line-height: 1.55;
|
||||
min-height: 100vh;
|
||||
}
|
||||
a { color: var(--accent-soft); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
.header {
|
||||
padding: 14px 24px;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--line);
|
||||
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0; font-size: 16px; font-weight: 700;
|
||||
color: var(--fg); max-width: 800px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.header .meta { font-size: 11px; color: var(--fg-faint); }
|
||||
.header .meta strong { color: var(--fg-dim); }
|
||||
.header .pill {
|
||||
display: inline-block; padding: 2px 8px; border-radius: 999px;
|
||||
font-size: 10px; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
background: rgba(129,140,248,0.18); color: var(--accent-soft);
|
||||
}
|
||||
|
||||
.split { display: flex; min-height: calc(100vh - 60px); }
|
||||
.left {
|
||||
flex: 0 0 42%; max-width: 42%;
|
||||
border-right: 1px solid var(--line);
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.right {
|
||||
flex: 1; min-width: 0;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
background: var(--panel);
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.split { flex-direction: column; }
|
||||
.left, .right { flex: none; max-width: 100%; border-right: none; }
|
||||
.left { border-bottom: 1px solid var(--line); max-height: 50vh; }
|
||||
}
|
||||
|
||||
/* Topic / chunk card */
|
||||
.chunk {
|
||||
padding: 12px 14px; margin-bottom: 8px;
|
||||
background: var(--panel); border: 1px solid var(--line);
|
||||
border-radius: 10px; cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.chunk:hover { border-color: var(--accent); }
|
||||
.chunk.active {
|
||||
border-color: var(--accent);
|
||||
background: rgba(129,140,248,0.06);
|
||||
box-shadow: 0 2px 16px rgba(129,140,248,0.10);
|
||||
}
|
||||
.chunk-title {
|
||||
font-size: 13px; font-weight: 700; color: var(--fg);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.chunk-time {
|
||||
font-size: 10px; color: var(--fg-faint);
|
||||
font-variant-numeric: tabular-nums; margin-left: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.chunk-summary {
|
||||
font-size: 12px; color: var(--fg-dim); line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Transcript pane */
|
||||
.transcript-line {
|
||||
display: flex; gap: 10px; padding: 4px 8px;
|
||||
border-radius: 6px; line-height: 1.6;
|
||||
scroll-margin-top: 80px;
|
||||
}
|
||||
.transcript-line.hl { background: rgba(129,140,248,0.10); }
|
||||
.ts-badge {
|
||||
flex: 0 0 auto;
|
||||
font-family: "SF Mono", Menlo, monospace;
|
||||
font-size: 11px; color: var(--accent-soft);
|
||||
min-width: 56px;
|
||||
}
|
||||
.ts-text { flex: 1; font-size: 13px; color: var(--fg); }
|
||||
|
||||
.empty {
|
||||
padding: 40px 20px; text-align: center;
|
||||
color: var(--fg-faint); font-size: 13px;
|
||||
}
|
||||
.error {
|
||||
padding: 20px; background: var(--panel);
|
||||
border: 1px solid var(--bad); border-radius: 10px;
|
||||
color: var(--bad); margin: 20px;
|
||||
}
|
||||
pre.raw {
|
||||
background: var(--panel); padding: 12px;
|
||||
border: 1px solid var(--line); border-radius: 8px;
|
||||
overflow: auto; font-size: 11px; color: var(--fg-dim);
|
||||
max-height: 300px; white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
<div class="empty">Loading job output…</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Strip script tags + on-event attrs from any HTML the data
|
||||
// accidentally contains. Transcript + analysis text comes from
|
||||
// Gemini / Parakeet so it's unlikely to contain malicious HTML
|
||||
// but escape it anyway — we're rendering server data in an
|
||||
// admin context.
|
||||
function esc(s) {
|
||||
if (s == null) return "";
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
// Parse "[MM:SS] text" or "[H:MM:SS] text" lines into entries
|
||||
// with offset-seconds + text. Mirrors Recap's parser so the
|
||||
// analysis startIndex/endIndex map onto the same entry indices.
|
||||
function parseTimestampedTranscript(text) {
|
||||
if (!text) return [];
|
||||
const entries = [];
|
||||
const re = /^\s*\[(\d+):(\d{2})(?::(\d{2}))?\]\s*(.*)$/;
|
||||
for (const line of String(text).split(/\r?\n/)) {
|
||||
const m = line.match(re);
|
||||
if (!m) continue;
|
||||
const hasHours = m[3] !== undefined;
|
||||
const offset = hasHours
|
||||
? parseInt(m[1], 10) * 3600 + parseInt(m[2], 10) * 60 + parseInt(m[3], 10)
|
||||
: parseInt(m[1], 10) * 60 + parseInt(m[2], 10);
|
||||
entries.push({ offset, text: m[4].trim() });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function formatTime(sec) {
|
||||
sec = Math.max(0, Math.round(sec || 0));
|
||||
const h = Math.floor(sec / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
const s = sec % 60;
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
||||
}
|
||||
|
||||
function getJobIdFromURL() {
|
||||
const u = new URL(location.href);
|
||||
return u.searchParams.get("id") || "";
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const root = document.getElementById("root");
|
||||
const jobId = getJobIdFromURL();
|
||||
if (!jobId) {
|
||||
root.innerHTML = '<div class="error">Missing ?id=<job_id> in URL.</div>';
|
||||
return;
|
||||
}
|
||||
let data;
|
||||
try {
|
||||
const r = await fetch("/admin/job-output/" + encodeURIComponent(jobId));
|
||||
if (r.status === 404) {
|
||||
root.innerHTML = '<div class="error">No stored output for job <code>' + esc(jobId) + '</code>. The output may have been deleted, or this job ran before output-storage was enabled.</div>';
|
||||
return;
|
||||
}
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
data = await r.json();
|
||||
} catch (err) {
|
||||
root.innerHTML = '<div class="error">Failed to load: ' + esc(err?.message || err) + '</div>';
|
||||
return;
|
||||
}
|
||||
render(data);
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
const root = document.getElementById("root");
|
||||
const meta = data.meta || {};
|
||||
const entries = parseTimestampedTranscript(data.transcript || "");
|
||||
const sections = (data.analysis && Array.isArray(data.analysis.sections))
|
||||
? data.analysis.sections
|
||||
: null;
|
||||
|
||||
// Header block — title, source, models, batch link back to dashboard.
|
||||
const sourceLabel = data.source === "admin-test"
|
||||
? '<span class="pill">Test run</span>'
|
||||
: (data.source === "admin-test-shared-tx" ? '<span class="pill">Shared TX</span>' : '');
|
||||
const headerHTML =
|
||||
'<div class="header">' +
|
||||
'<h1>' + esc(meta.title || meta.media_url || data.job_id) + '</h1>' +
|
||||
sourceLabel +
|
||||
'<div class="meta">' +
|
||||
(meta.media_url ? '<a href="' + esc(meta.media_url) + '" target="_blank" rel="noopener">source ↗</a> · ' : '') +
|
||||
(meta.audio_seconds ? '<strong>' + formatTime(meta.audio_seconds) + '</strong> audio · ' : '') +
|
||||
(meta.transcribe_backend ? 'TX: <strong>' + esc(meta.transcribe_model || meta.transcribe_backend) + '</strong> · ' : '') +
|
||||
(meta.analyze_backend ? 'AN: <strong>' + esc(meta.analyze_model || meta.analyze_backend) + '</strong>' : '') +
|
||||
'</div>' +
|
||||
'<div style="margin-left:auto;"><a href="/" title="Back to dashboard">← Dashboard</a></div>' +
|
||||
'</div>';
|
||||
|
||||
// Empty states.
|
||||
if (entries.length === 0) {
|
||||
root.innerHTML = headerHTML +
|
||||
'<div class="empty">No transcript text was saved for this job.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Left pane: topic list.
|
||||
let leftHTML = '<div class="left" id="topics-pane">';
|
||||
if (!sections || sections.length === 0) {
|
||||
leftHTML += '<div class="empty">No analysis sections were saved for this job.';
|
||||
if (data.analysis_raw_text) {
|
||||
leftHTML += '<pre class="raw" style="text-align:left; margin-top: 12px;">' + esc(data.analysis_raw_text.slice(0, 4000)) + '</pre>';
|
||||
}
|
||||
leftHTML += '</div>';
|
||||
} else {
|
||||
sections.forEach((s, i) => {
|
||||
const startIdx = Math.max(0, Math.min(s.startIndex || 0, entries.length - 1));
|
||||
const startTs = entries[startIdx]?.offset || 0;
|
||||
const endIdx = Math.max(startIdx, Math.min(s.endIndex || 0, entries.length - 1));
|
||||
const endTs = entries[endIdx]?.offset || 0;
|
||||
leftHTML +=
|
||||
'<div class="chunk" data-section-idx="' + i + '" data-start="' + startIdx + '" onclick="onChunkClick(' + i + ')">' +
|
||||
'<div class="chunk-title">' +
|
||||
esc(s.title || "(untitled)") +
|
||||
'<span class="chunk-time">' + formatTime(startTs) + ' — ' + formatTime(endTs) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="chunk-summary">' + esc(s.summary || "") + '</div>' +
|
||||
'</div>';
|
||||
});
|
||||
}
|
||||
leftHTML += '</div>';
|
||||
|
||||
// Right pane: transcript.
|
||||
let rightHTML = '<div class="right" id="transcript-pane">';
|
||||
entries.forEach((e, i) => {
|
||||
rightHTML +=
|
||||
'<div class="transcript-line" id="entry-' + i + '">' +
|
||||
'<span class="ts-badge">' + formatTime(e.offset) + '</span>' +
|
||||
'<span class="ts-text">' + esc(e.text) + '</span>' +
|
||||
'</div>';
|
||||
});
|
||||
rightHTML += '</div>';
|
||||
|
||||
root.innerHTML = headerHTML + '<div class="split">' + leftHTML + rightHTML + '</div>';
|
||||
|
||||
// Expose for click handlers.
|
||||
window._entries = entries;
|
||||
window._sections = sections;
|
||||
}
|
||||
|
||||
// Click a topic in the left pane: scroll the matching entry into
|
||||
// view on the right pane and apply a highlight band over the
|
||||
// section's entry range. Highlight clears after a couple seconds.
|
||||
function onChunkClick(sectionIdx) {
|
||||
const sections = window._sections;
|
||||
if (!sections || !sections[sectionIdx]) return;
|
||||
// Mark active chunk visually.
|
||||
document.querySelectorAll(".chunk.active").forEach((el) => el.classList.remove("active"));
|
||||
const chunkEl = document.querySelector('.chunk[data-section-idx="' + sectionIdx + '"]');
|
||||
if (chunkEl) chunkEl.classList.add("active");
|
||||
|
||||
const s = sections[sectionIdx];
|
||||
const start = Math.max(0, s.startIndex || 0);
|
||||
const end = Math.max(start, s.endIndex || start);
|
||||
// Scroll the start entry into view in the transcript pane.
|
||||
const target = document.getElementById("entry-" + start);
|
||||
if (target) target.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
// Highlight the section's range; clear prior highlights first.
|
||||
document.querySelectorAll(".transcript-line.hl").forEach((el) => el.classList.remove("hl"));
|
||||
for (let i = start; i <= end; i++) {
|
||||
const el = document.getElementById("entry-" + i);
|
||||
if (el) el.classList.add("hl");
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user