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>
273 lines
7.9 KiB
Svelte
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}
|