analyze_srt.py, build_page.py and transcode.yaml produced the /bygge prototype. They were sitting in a session scratchpad on tmpfs, one reboot from gone, while spike issues #29-#31 were written as if they had to be built from scratch. Committed as found, defects documented rather than fixed: max_tokens=6000 truncates anything past ~6 minutes of audio, the page title is hardcoded, and the schema still carries fields no consumer renders. The four-check validator is the part worth keeping — the coverage-gap and Swedish-number-grounding checks catch errors that timestamp and citation checks structurally cannot. Real transcripts and analyses stay out: this repo is public and the recordings are a named person discussing a client's project. Only the synthetic fixture is committed, which exercises all four checks with no model call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m
358 lines
14 KiB
Python
358 lines
14 KiB
Python
#!/usr/bin/env python3
|
||
"""Bygg en fristaende HTML-sida: video + SRT + tidssynkad analys.
|
||
|
||
Sidan ar sjalvbarande sa nar som pa videofilen - transkription och analys
|
||
bakas in i HTML:en. Det gor att den fungerar aven over file:// (fetch mot
|
||
en lokal JSON blockeras av CORS).
|
||
|
||
python3 build_page.py payload.json IMG_1233.web.mp4 > index.html
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import html
|
||
import json
|
||
import sys
|
||
|
||
TEMPLATE = """<!doctype html>
|
||
<html lang="sv">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>__TITLE__</title>
|
||
<style>
|
||
:root {
|
||
--bg:#f6f7f9; --panel:#fff; --ink:#16181d; --muted:#6b7280; --line:#e3e6ea;
|
||
--accent:#2563eb; --accent-soft:#eff4ff;
|
||
--beslut:#0f9d58; --alt:#d97706; --oppen:#dc2626; --matt:#7c3aed;
|
||
}
|
||
@media (prefers-color-scheme: dark) {
|
||
:root {
|
||
--bg:#0e1116; --panel:#161a21; --ink:#e6e9ef; --muted:#9aa3b2; --line:#262c36;
|
||
--accent:#60a5fa; --accent-soft:#17233b;
|
||
--beslut:#34d399; --alt:#fbbf24; --oppen:#f87171; --matt:#a78bfa;
|
||
}
|
||
}
|
||
:root[data-theme="dark"] {
|
||
--bg:#0e1116; --panel:#161a21; --ink:#e6e9ef; --muted:#9aa3b2; --line:#262c36;
|
||
--accent:#60a5fa; --accent-soft:#17233b;
|
||
--beslut:#34d399; --alt:#fbbf24; --oppen:#f87171; --matt:#a78bfa;
|
||
}
|
||
:root[data-theme="light"] {
|
||
--bg:#f6f7f9; --panel:#fff; --ink:#16181d; --muted:#6b7280; --line:#e3e6ea;
|
||
--accent:#2563eb; --accent-soft:#eff4ff;
|
||
--beslut:#0f9d58; --alt:#d97706; --oppen:#dc2626; --matt:#7c3aed;
|
||
}
|
||
* { box-sizing:border-box; }
|
||
body {
|
||
margin:0; background:var(--bg); color:var(--ink);
|
||
font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
||
}
|
||
header {
|
||
padding:14px 20px; border-bottom:1px solid var(--line); background:var(--panel);
|
||
display:flex; align-items:baseline; gap:14px; flex-wrap:wrap;
|
||
}
|
||
header h1 { margin:0; font-size:17px; font-weight:650; }
|
||
header .sub { color:var(--muted); font-size:13px; }
|
||
|
||
.wrap {
|
||
display:grid; grid-template-columns: minmax(0,1.35fr) minmax(320px,1fr);
|
||
gap:20px; padding:20px; align-items:start; max-width:1600px; margin:0 auto;
|
||
}
|
||
@media (max-width: 950px) { .wrap { grid-template-columns:1fr; } }
|
||
|
||
/* Videon star stilla; analysen ror sig bredvid. */
|
||
.videocol { position:sticky; top:20px; }
|
||
video { width:100%; border-radius:10px; background:#000; display:block; }
|
||
|
||
.nowbar {
|
||
margin-top:10px; padding:12px 14px; background:var(--panel);
|
||
border:1px solid var(--line); border-radius:10px; min-height:64px;
|
||
}
|
||
.nowbar .label { font-size:11px; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); }
|
||
.nowbar .text { margin-top:4px; font-size:16px; }
|
||
|
||
.tabs { display:flex; gap:6px; margin-bottom:12px; flex-wrap:wrap; }
|
||
.tab {
|
||
padding:6px 12px; border:1px solid var(--line); background:var(--panel);
|
||
border-radius:999px; cursor:pointer; font-size:13px; color:var(--ink);
|
||
}
|
||
.tab[aria-selected="true"] { background:var(--accent); border-color:var(--accent); color:#fff; }
|
||
|
||
.pane { display:none; }
|
||
.pane.active { display:block; }
|
||
|
||
.scroller {
|
||
max-height: calc(100vh - 190px); overflow-y:auto; padding-right:6px;
|
||
scroll-behavior:smooth;
|
||
}
|
||
|
||
.card {
|
||
background:var(--panel); border:1px solid var(--line); border-left:3px solid var(--line);
|
||
border-radius:8px; padding:11px 13px; margin-bottom:9px; cursor:pointer;
|
||
transition:border-color .15s, background .15s;
|
||
}
|
||
.card:hover { border-color:var(--accent); }
|
||
.card.on { background:var(--accent-soft); border-left-color:var(--accent); }
|
||
.card .k {
|
||
font-size:10px; text-transform:uppercase; letter-spacing:.07em;
|
||
font-weight:700; margin-bottom:5px;
|
||
}
|
||
.card .t { font-size:11px; color:var(--muted); font-variant-numeric:tabular-nums; }
|
||
.card .cit { display:block; margin-top:6px; color:var(--muted); font-size:13px; font-style:italic; }
|
||
.k.beslut{color:var(--beslut)} .k.alt{color:var(--alt)}
|
||
.k.oppen{color:var(--oppen)} .k.matt{color:var(--matt)}
|
||
.card.beslut{border-left-color:var(--beslut)} .card.alt{border-left-color:var(--alt)}
|
||
.card.oppen{border-left-color:var(--oppen)} .card.matt{border-left-color:var(--matt)}
|
||
.opts { margin:6px 0 0; padding-left:18px; font-size:13px; }
|
||
.lutar { font-size:12px; color:var(--muted); margin-top:5px; }
|
||
|
||
.cue {
|
||
padding:5px 9px; border-radius:6px; cursor:pointer; display:flex; gap:10px;
|
||
font-size:14px; border-left:2px solid transparent;
|
||
}
|
||
.cue:hover { background:var(--accent-soft); }
|
||
.cue.on { background:var(--accent-soft); border-left-color:var(--accent); font-weight:600; }
|
||
.cue .ts {
|
||
color:var(--muted); font-variant-numeric:tabular-nums; font-size:12px;
|
||
flex:0 0 46px; padding-top:2px;
|
||
}
|
||
.sec-head {
|
||
margin:16px 0 7px; font-size:12px; font-weight:700; text-transform:uppercase;
|
||
letter-spacing:.06em; color:var(--muted); border-bottom:1px solid var(--line);
|
||
padding-bottom:5px;
|
||
}
|
||
.sec-head:first-child { margin-top:0; }
|
||
.warn {
|
||
background:var(--panel); border:1px solid var(--line); border-left:3px solid var(--alt);
|
||
border-radius:8px; padding:11px 13px; font-size:13px; color:var(--muted);
|
||
}
|
||
.warn b { color:var(--ink); }
|
||
.warn ul { margin:6px 0 0; padding-left:18px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>__TITLE__</h1>
|
||
<span class="sub">__SUB__</span>
|
||
</header>
|
||
|
||
<div class="wrap">
|
||
<div class="videocol">
|
||
<video id="v" controls preload="metadata" src="__VIDEO__"></video>
|
||
<div class="nowbar">
|
||
<div class="label">Sägs nu</div>
|
||
<div class="text" id="now">—</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div class="tabs" role="tablist">
|
||
<button class="tab" role="tab" aria-selected="true" data-pane="analys">Analys</button>
|
||
<button class="tab" role="tab" aria-selected="false" data-pane="text">Transkription</button>
|
||
<button class="tab" role="tab" aria-selected="false" data-pane="kvalitet">Kvalitet</button>
|
||
</div>
|
||
<div class="pane active" id="pane-analys"><div class="scroller" id="analys"></div></div>
|
||
<div class="pane" id="pane-text"><div class="scroller" id="text"></div></div>
|
||
<div class="pane" id="pane-kvalitet"><div class="scroller" id="kvalitet"></div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const DATA = __DATA__;
|
||
const v = document.getElementById('v');
|
||
|
||
const hms = s => {
|
||
s = Math.max(0, Math.floor(s));
|
||
return String(Math.floor(s/60)).padStart(2,'0') + ':' + String(s%60).padStart(2,'0');
|
||
};
|
||
const toSec = t => {
|
||
if (t == null) return null;
|
||
const p = String(t).trim().split(':').map(Number);
|
||
if (p.some(isNaN)) return null;
|
||
return p.length === 3 ? p[0]*3600+p[1]*60+p[2] : p.length === 2 ? p[0]*60+p[1] : p[0];
|
||
};
|
||
const esc = s => { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; };
|
||
|
||
/* Varje kort bar sin tid; ett klick soker dit. Analysen ar tidsforankrad
|
||
hela vagen ned, sa synkningen ar bara en sortering pa den tiden. */
|
||
const items = [];
|
||
const A = DATA.analys;
|
||
|
||
(A.avsnitt || []).forEach((s, i) => {
|
||
const t = toSec(s.start) ?? 0;
|
||
items.push({t, end: toSec(s.slut) ?? 1e9, kind:'avsnitt', html:
|
||
`<div class="k">Avsnitt ${i+1} · ${esc(s.start)}–${esc(s.slut)}</div>
|
||
<div style="font-weight:650;font-size:15px">${esc(s.rubrik)}</div>
|
||
<div style="margin-top:5px">${esc(s.sammanfattning)}</div>`});
|
||
(s.nyckelpunkter || []).forEach(k => {
|
||
const kt = toSec(k.tid); if (kt == null) return;
|
||
items.push({t:kt, end:kt+6, kind:'punkt', citat:k.citat, html:
|
||
`<div class="t">${esc(k.tid)}</div><div>${esc(k.pastaende)}</div>
|
||
<div class="cit">”${esc(k.citat)}”</div>`});
|
||
});
|
||
});
|
||
(A.beslut || []).forEach(b => {
|
||
const t = toSec(b.tid); if (t == null) return;
|
||
items.push({t, end:t+8, kind:'beslut', citat:b.citat, html:
|
||
`<div class="k beslut">Beslut</div><div class="t">${esc(b.tid)}</div>
|
||
<div style="font-weight:600">${esc(b.vad)}</div>
|
||
${b.skal ? `<div class="lutar">Skäl: ${esc(b.skal)}</div>` : ''}
|
||
<div class="cit">”${esc(b.citat)}”</div>`});
|
||
});
|
||
(A.alternativ || []).forEach(a => {
|
||
const t = toSec(a.tid); if (t == null) return;
|
||
items.push({t, end:t+8, kind:'alt', citat:a.citat, html:
|
||
`<div class="k alt">Alternativ</div><div class="t">${esc(a.tid)}</div>
|
||
<div style="font-weight:600">${esc(a.fraga)}</div>
|
||
<ul class="opts">${(a.optioner||[]).map(o=>`<li>${esc(o)}</li>`).join('')}</ul>
|
||
<div class="lutar">${a.lutar_at ? 'Lutar åt: <b>'+esc(a.lutar_at)+'</b>' : 'Oavgjort'}</div>
|
||
<div class="cit">”${esc(a.citat)}”</div>`});
|
||
});
|
||
(A.oppna_fragor || []).forEach(o => {
|
||
const t = toSec(o.tid); if (t == null) return;
|
||
items.push({t, end:t+8, kind:'oppen', citat:o.citat, html:
|
||
`<div class="k oppen">Öppen fråga</div><div class="t">${esc(o.tid)}</div>
|
||
<div style="font-weight:600">${esc(o.fraga)}</div>
|
||
${o.beror_pa ? `<div class="lutar">Beror på: ${esc(o.beror_pa)}</div>` : ''}
|
||
<div class="cit">”${esc(o.citat)}”</div>`});
|
||
});
|
||
(A.matt || []).forEach(m => {
|
||
const t = toSec(m.tid); if (t == null) return;
|
||
items.push({t, end:t+6, kind:'matt', citat:m.citat, html:
|
||
`<div class="k matt">Mått</div><div class="t">${esc(m.tid)}</div>
|
||
<div>${esc(m.vad)}</div><div class="cit">”${esc(m.citat)}”</div>`});
|
||
});
|
||
|
||
const RANK = {avsnitt:0, beslut:1, oppen:2, alt:3, matt:4, punkt:5};
|
||
/* Samma replik klassas ofta bade som nyckelpunkt och som matt/beslut.
|
||
Visa den mest specifika varianten en gang i stallet for tva nastan
|
||
identiska kort - annars later flodet som ett eko. */
|
||
const bestByQuote = new Map();
|
||
items.forEach(it => {
|
||
const key = (it.citat||'').trim().toLowerCase() + '@' + it.t;
|
||
if (!it.citat) return;
|
||
const prev = bestByQuote.get(key);
|
||
if (prev === undefined || RANK[it.kind] < RANK[prev.kind]) bestByQuote.set(key, it);
|
||
});
|
||
const kept = items.filter(it => {
|
||
if (!it.citat) return true;
|
||
const key = (it.citat||'').trim().toLowerCase() + '@' + it.t;
|
||
return bestByQuote.get(key) === it;
|
||
});
|
||
items.length = 0; items.push(...kept);
|
||
items.sort((a,b) => a.t - b.t || RANK[a.kind] - RANK[b.kind]);
|
||
|
||
const analysEl = document.getElementById('analys');
|
||
analysEl.innerHTML = items.map((it,i) =>
|
||
`<div class="card ${it.kind}" data-i="${i}" data-t="${it.t}">${it.html}</div>`).join('');
|
||
|
||
const textEl = document.getElementById('text');
|
||
textEl.innerHTML = DATA.cues.map((c,i) =>
|
||
`<div class="cue" data-ci="${i}" data-t="${c.t}">
|
||
<span class="ts">${hms(c.t)}</span><span>${esc(c.text)}</span></div>`).join('');
|
||
|
||
const warns = (A._meta && A._meta.varningar) || [];
|
||
document.getElementById('kvalitet').innerHTML = `
|
||
<div class="warn">
|
||
<b>Automatisk källkontroll</b><br>
|
||
Modell: ${esc((A._meta&&A._meta.modell)||'–')} · ${DATA.cues.length} repliker · längd ${esc((A._meta&&A._meta.langd)||'–')}<br><br>
|
||
Varje påstående kontrolleras mot sitt eget citat: citatet måste återfinnas
|
||
ordagrant i transkriptionen, tiden måste ligga inom klippet, och varje tal i
|
||
påståendet måste styrkas av citatet.
|
||
${warns.length
|
||
? `<br><br><b>${warns.length} varningar:</b><ul>${warns.map(w=>`<li>${esc(w)}</li>`).join('')}</ul>`
|
||
: '<br><br>Inga avvikelser.'}
|
||
</div>`;
|
||
|
||
/* Klick söker i videon - åt båda håll: kort och replik. */
|
||
document.addEventListener('click', e => {
|
||
const el = e.target.closest('.card, .cue');
|
||
if (!el) return;
|
||
v.currentTime = parseFloat(el.dataset.t) + 0.05;
|
||
v.play().catch(()=>{});
|
||
});
|
||
|
||
document.querySelectorAll('.tab').forEach(tab => {
|
||
tab.addEventListener('click', () => {
|
||
document.querySelectorAll('.tab').forEach(t => t.setAttribute('aria-selected','false'));
|
||
tab.setAttribute('aria-selected','true');
|
||
document.querySelectorAll('.pane').forEach(p => p.classList.remove('active'));
|
||
document.getElementById('pane-' + tab.dataset.pane).classList.add('active');
|
||
});
|
||
});
|
||
|
||
/* Auto-scroll bara nar anvandaren inte sjalv scrollar - annars slits vyn
|
||
ur handerna pa den som lasar. */
|
||
let userScrolled = 0;
|
||
document.querySelectorAll('.scroller').forEach(s =>
|
||
s.addEventListener('wheel', () => { userScrolled = Date.now(); }, {passive:true}));
|
||
|
||
let lastCard = -1, lastCue = -1;
|
||
function sync() {
|
||
const t = v.currentTime;
|
||
|
||
let ci = -1;
|
||
for (let i = 0; i < DATA.cues.length; i++) {
|
||
if (t >= DATA.cues[i].t - 0.15) ci = i; else break;
|
||
}
|
||
if (ci !== lastCue) {
|
||
document.querySelectorAll('.cue.on').forEach(e => e.classList.remove('on'));
|
||
if (ci >= 0) {
|
||
const el = textEl.querySelector(`[data-ci="${ci}"]`);
|
||
if (el) {
|
||
el.classList.add('on');
|
||
if (Date.now() - userScrolled > 3000) el.scrollIntoView({block:'center'});
|
||
}
|
||
document.getElementById('now').textContent = DATA.cues[ci].text;
|
||
}
|
||
lastCue = ci;
|
||
}
|
||
|
||
let ai = -1;
|
||
for (let i = 0; i < items.length; i++) {
|
||
if (t >= items[i].t - 0.15) ai = i; else break;
|
||
}
|
||
if (ai !== lastCard) {
|
||
document.querySelectorAll('.card.on').forEach(e => e.classList.remove('on'));
|
||
if (ai >= 0) {
|
||
const el = analysEl.querySelector(`[data-i="${ai}"]`);
|
||
if (el) {
|
||
el.classList.add('on');
|
||
if (Date.now() - userScrolled > 3000 &&
|
||
document.getElementById('pane-analys').classList.contains('active')) {
|
||
el.scrollIntoView({block:'center'});
|
||
}
|
||
}
|
||
}
|
||
lastCard = ai;
|
||
}
|
||
requestAnimationFrame(sync);
|
||
}
|
||
requestAnimationFrame(sync);
|
||
</script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
def main() -> None:
|
||
payload_path, video = sys.argv[1], sys.argv[2]
|
||
payload = json.load(open(payload_path, encoding="utf-8"))
|
||
a = payload["analys"]
|
||
proj = a.get("projekt") or {}
|
||
sub = " · ".join(
|
||
x for x in [proj.get("typ"), (a.get("_meta") or {}).get("langd"), video] if x
|
||
)
|
||
out = (
|
||
TEMPLATE.replace("__TITLE__", html.escape("Jonas – genomgång av tillbyggnad"))
|
||
.replace("__SUB__", html.escape(sub))
|
||
.replace("__VIDEO__", html.escape(video))
|
||
.replace("__DATA__", json.dumps(payload, ensure_ascii=False))
|
||
)
|
||
sys.stdout.write(out)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|