synops/frontend/src/lib/components/RetranscriptionCompare.svelte
vegard b4ee80a97b Fullfør oppgave 7.7: Re-transkripsjonsflyt med side-om-side-sammenligning
Ny funksjonalitet for å kjøre re-transkripsjon på eksisterende media-noder
og sammenligne gammel vs ny versjon per segment. Manuelt redigerte segmenter
fra forrige versjon blir uthevet, og brukeren velger per segment hvilken
versjon som skal beholdes.

Backend (Rust):
- POST /intentions/retranscribe — trigger ny Whisper-jobb for media-node
- GET /query/transcription_versions — list alle versjoner for en node
- GET /query/segments_version — hent segmenter for spesifikk versjon
- POST /intentions/resolve_retranscription — anvend per-segment-valg

Frontend (Svelte):
- RetranscriptionCompare.svelte — side-om-side visning med per-segment-valg
- TranscriptionView: re-transkriber-knapp, auto-detect nye versjoner, polling
- API-klient: nye funksjoner for alle re-transkripsjonsendepunkter

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 18:41:09 +01:00

273 lines
7.9 KiB
Svelte

<script lang="ts">
import {
fetchSegmentsVersion,
resolveRetranscription,
type Segment,
type SegmentChoice
} from '$lib/api';
interface Props {
nodeId: string;
accessToken: string;
/** transcribed_at for den nye versjonen */
newVersion: string;
/** transcribed_at for den gamle versjonen */
oldVersion: string;
/** Callback når sammenligning er ferdig (resolved eller avbrutt) */
ondone?: () => void;
/** Callback for å hoppe til tidspunkt i lydfilen */
onseek?: (timeSeconds: number) => void;
/** Nåværende avspillingstid i sekunder */
currentTime?: number;
}
let {
nodeId,
accessToken,
newVersion,
oldVersion,
ondone,
onseek,
currentTime = 0
}: Props = $props();
let oldSegments: Segment[] = $state([]);
let newSegments: Segment[] = $state([]);
let loading = $state(true);
let error = $state<string | null>(null);
let saving = $state(false);
// Per-segment valg: seq → 'new' | 'old'. Default: 'new' for alle.
let choices: Record<number, 'new' | 'old'> = $state({});
$effect(() => {
loadVersions();
});
async function loadVersions() {
loading = true;
error = null;
try {
const [oldResp, newResp] = await Promise.all([
fetchSegmentsVersion(accessToken, nodeId, oldVersion),
fetchSegmentsVersion(accessToken, nodeId, newVersion)
]);
oldSegments = oldResp.segments;
newSegments = newResp.segments;
// Default: velg 'new' for alle, men 'old' for redigerte segmenter
const c: Record<number, 'new' | 'old'> = {};
for (const seg of newSegments) {
// Finn matchende gammelt segment
const oldSeg = oldSegments.find((o) => o.seq === seg.seq);
c[seg.seq] = oldSeg?.edited ? 'old' : 'new';
}
choices = c;
} catch (e) {
error = e instanceof Error ? e.message : 'Kunne ikke laste versjoner';
} finally {
loading = false;
}
}
function formatTimestamp(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
function handleSeek(startMs: number) {
onseek?.(startMs / 1000);
}
function toggleChoice(seq: number) {
choices[seq] = choices[seq] === 'new' ? 'old' : 'new';
}
function selectAllNew() {
const c: Record<number, 'new' | 'old'> = {};
for (const seg of newSegments) c[seg.seq] = 'new';
choices = c;
}
function selectAllOld() {
const c: Record<number, 'new' | 'old'> = {};
for (const seg of newSegments) {
const oldSeg = oldSegments.find((o) => o.seq === seg.seq);
c[seg.seq] = oldSeg ? 'old' : 'new';
}
choices = c;
}
const stats = $derived.by(() => {
let kept_old = 0;
let kept_new = 0;
for (const v of Object.values(choices)) {
if (v === 'old') kept_old++;
else kept_new++;
}
return { kept_old, kept_new };
});
async function applyChoices() {
saving = true;
try {
const segChoices: SegmentChoice[] = Object.entries(choices).map(([seq, choice]) => ({
seq: parseInt(seq),
choice
}));
await resolveRetranscription(accessToken, nodeId, newVersion, oldVersion, segChoices);
ondone?.();
} catch (e) {
error = e instanceof Error ? e.message : 'Kunne ikke lagre valg';
} finally {
saving = false;
}
}
/** Sjekk om gammel og ny tekst er forskjellige for et segment */
function isDifferent(seq: number): boolean {
const oldSeg = oldSegments.find((o) => o.seq === seq);
const newSeg = newSegments.find((n) => n.seq === seq);
if (!oldSeg || !newSeg) return true;
return oldSeg.content.trim() !== newSeg.content.trim();
}
</script>
{#if loading}
<p class="text-xs text-gray-400 px-2 py-2">Laster sammenligning...</p>
{:else if error}
<p class="text-xs text-red-400 px-2 py-2">{error}</p>
{:else}
<div class="border border-blue-200 rounded-lg bg-blue-50/30 p-2 space-y-2">
<!-- Header -->
<div class="flex items-center justify-between">
<h3 class="text-xs font-semibold text-gray-700">Sammenlign transkripsjoner</h3>
<div class="flex gap-1">
<button
onclick={selectAllNew}
class="text-[10px] px-1.5 py-0.5 rounded bg-green-100 text-green-700 hover:bg-green-200"
>
Alle nye
</button>
<button
onclick={selectAllOld}
class="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 hover:bg-amber-200"
>
Alle gamle
</button>
</div>
</div>
<!-- Segment-liste -->
<div class="max-h-80 overflow-y-auto space-y-1">
{#each newSegments as newSeg (newSeg.seq)}
{@const oldSeg = oldSegments.find((o) => o.seq === newSeg.seq)}
{@const choice = choices[newSeg.seq] ?? 'new'}
{@const different = isDifferent(newSeg.seq)}
{@const isActive =
currentTime * 1000 >= newSeg.start_ms && currentTime * 1000 < newSeg.end_ms}
<div
class="rounded border px-2 py-1.5 transition-colors
{isActive ? 'border-blue-300 bg-blue-50' : 'border-gray-200 bg-white'}"
>
<!-- Tidsstempel -->
<div class="flex items-center justify-between mb-1">
<button
onclick={() => handleSeek(newSeg.start_ms)}
class="text-[10px] font-mono text-gray-400 hover:text-blue-500 flex items-center gap-0.5"
>
<svg class="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
{formatTimestamp(newSeg.start_ms)}
</button>
{#if different && oldSeg}
<button
onclick={() => toggleChoice(newSeg.seq)}
class="text-[10px] px-1.5 py-0.5 rounded font-medium transition-colors
{choice === 'old'
? 'bg-amber-100 text-amber-700 hover:bg-amber-200'
: 'bg-green-100 text-green-700 hover:bg-green-200'}"
>
{choice === 'old' ? 'Beholder gammel' : 'Bruker ny'}
</button>
{:else if !oldSeg}
<span class="text-[10px] text-green-600">Nytt segment</span>
{:else}
<span class="text-[10px] text-gray-400">Uendret</span>
{/if}
</div>
{#if different && oldSeg}
<!-- Side-by-side visning -->
<div class="grid grid-cols-2 gap-1.5">
<!-- Gammel versjon -->
<button
onclick={() => {
choices[newSeg.seq] = 'old';
}}
class="text-left text-[11px] leading-relaxed p-1.5 rounded border transition-colors
{choice === 'old'
? 'border-amber-300 bg-amber-50 text-gray-800'
: 'border-gray-200 bg-gray-50 text-gray-500 hover:border-amber-200'}"
>
{#if oldSeg.edited}
<span
class="text-[9px] font-medium text-amber-600 block mb-0.5"
>redigert</span
>
{/if}
{oldSeg.content}
</button>
<!-- Ny versjon -->
<button
onclick={() => {
choices[newSeg.seq] = 'new';
}}
class="text-left text-[11px] leading-relaxed p-1.5 rounded border transition-colors
{choice === 'new'
? 'border-green-300 bg-green-50 text-gray-800'
: 'border-gray-200 bg-gray-50 text-gray-500 hover:border-green-200'}"
>
{newSeg.content}
</button>
</div>
{:else}
<!-- Identisk eller bare ny — vis enkelt -->
<p class="text-[11px] text-gray-600 leading-relaxed">
{newSeg.content}
</p>
{/if}
</div>
{/each}
</div>
<!-- Footer med handlinger -->
<div class="flex items-center justify-between pt-1 border-t border-blue-200">
<span class="text-[10px] text-gray-500">
{stats.kept_new} nye, {stats.kept_old} gamle
</span>
<div class="flex gap-1.5">
<button
onclick={() => ondone?.()}
disabled={saving}
class="text-[10px] px-2 py-1 rounded text-gray-500 hover:text-gray-700 hover:bg-gray-100"
>
Avbryt
</button>
<button
onclick={applyChoices}
disabled={saving}
class="text-[10px] px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:bg-gray-300 font-medium"
>
{saving ? 'Lagrer...' : 'Bekreft valg'}
</button>
</div>
</div>
</div>
{/if}