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>
This commit is contained in:
parent
7233259d9d
commit
b4ee80a97b
8 changed files with 932 additions and 11 deletions
|
|
@ -175,3 +175,82 @@ export function updateSegment(
|
|||
content
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Re-transkripsjon
|
||||
// =============================================================================
|
||||
|
||||
export interface TranscriptionVersion {
|
||||
transcribed_at: string;
|
||||
segment_count: number;
|
||||
edited_count: number;
|
||||
}
|
||||
|
||||
export interface TranscriptionVersionsResponse {
|
||||
versions: TranscriptionVersion[];
|
||||
}
|
||||
|
||||
/** Hent alle transkripsjonsversjoner for en node. */
|
||||
export async function fetchTranscriptionVersions(
|
||||
accessToken: string,
|
||||
nodeId: string
|
||||
): Promise<TranscriptionVersionsResponse> {
|
||||
const res = await fetch(
|
||||
`${BASE_URL}/query/transcription_versions?node_id=${encodeURIComponent(nodeId)}`,
|
||||
{ headers: { Authorization: `Bearer ${accessToken}` } }
|
||||
);
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`transcription_versions failed (${res.status}): ${body}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Hent segmenter for en spesifikk transkripsjonsversjon. */
|
||||
export async function fetchSegmentsVersion(
|
||||
accessToken: string,
|
||||
nodeId: string,
|
||||
transcribedAt: string
|
||||
): Promise<SegmentsResponse> {
|
||||
const params = new URLSearchParams({
|
||||
node_id: nodeId,
|
||||
transcribed_at: transcribedAt
|
||||
});
|
||||
const res = await fetch(`${BASE_URL}/query/segments_version?${params}`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` }
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`segments_version failed (${res.status}): ${body}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Trigger re-transkripsjon for en media-node. */
|
||||
export function retranscribe(
|
||||
accessToken: string,
|
||||
nodeId: string
|
||||
): Promise<{ job_id: string }> {
|
||||
return post(accessToken, '/intentions/retranscribe', { node_id: nodeId });
|
||||
}
|
||||
|
||||
export interface SegmentChoice {
|
||||
seq: number;
|
||||
choice: 'new' | 'old';
|
||||
}
|
||||
|
||||
/** Anvend brukerens per-segment-valg etter re-transkripsjon. */
|
||||
export function resolveRetranscription(
|
||||
accessToken: string,
|
||||
nodeId: string,
|
||||
newVersion: string,
|
||||
oldVersion: string,
|
||||
choices: SegmentChoice[]
|
||||
): Promise<{ resolved: boolean; kept_old: number; kept_new: number }> {
|
||||
return post(accessToken, '/intentions/resolve_retranscription', {
|
||||
node_id: nodeId,
|
||||
new_version: newVersion,
|
||||
old_version: oldVersion,
|
||||
choices
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -158,8 +158,8 @@
|
|||
{#if showTranscript}
|
||||
{#if hasSegments}
|
||||
<TranscriptionView
|
||||
{nodeId}
|
||||
{accessToken}
|
||||
nodeId={nodeId!}
|
||||
accessToken={accessToken!}
|
||||
{currentTime}
|
||||
onseek={seekTo}
|
||||
/>
|
||||
|
|
|
|||
273
frontend/src/lib/components/RetranscriptionCompare.svelte
Normal file
273
frontend/src/lib/components/RetranscriptionCompare.svelte
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
<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}
|
||||
|
|
@ -1,5 +1,13 @@
|
|||
<script lang="ts">
|
||||
import { fetchSegments, updateSegment, type Segment } from '$lib/api';
|
||||
import {
|
||||
fetchSegments,
|
||||
updateSegment,
|
||||
fetchTranscriptionVersions,
|
||||
retranscribe,
|
||||
type Segment,
|
||||
type TranscriptionVersion
|
||||
} from '$lib/api';
|
||||
import RetranscriptionCompare from './RetranscriptionCompare.svelte';
|
||||
|
||||
interface Props {
|
||||
/** Media node ID — brukes for å hente segmenter fra API */
|
||||
|
|
@ -21,7 +29,13 @@
|
|||
let editText = $state('');
|
||||
let saving = $state(false);
|
||||
|
||||
// Hent segmenter ved mount og når nodeId endres
|
||||
// Re-transkripsjon state
|
||||
let versions: TranscriptionVersion[] = $state([]);
|
||||
let retranscribing = $state(false);
|
||||
let showCompare = $state(false);
|
||||
let transcribedAt = $state<string | null>(null);
|
||||
|
||||
// Hent segmenter og versjoner ved mount og når nodeId endres
|
||||
$effect(() => {
|
||||
loadSegments(nodeId, accessToken);
|
||||
});
|
||||
|
|
@ -30,8 +44,21 @@
|
|||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const resp = await fetchSegments(token, nid);
|
||||
segments = resp.segments;
|
||||
const [segResp, versResp] = await Promise.all([
|
||||
fetchSegments(token, nid),
|
||||
fetchTranscriptionVersions(token, nid)
|
||||
]);
|
||||
segments = segResp.segments;
|
||||
transcribedAt = segResp.transcribed_at;
|
||||
versions = versResp.versions;
|
||||
|
||||
// Hvis det finnes 2+ versjoner og den gamle har redigerte segmenter,
|
||||
// vis sammenligningsvisning automatisk
|
||||
if (versions.length >= 2 && versions[1].edited_count > 0) {
|
||||
showCompare = true;
|
||||
} else {
|
||||
showCompare = false;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Kunne ikke hente segmenter';
|
||||
} finally {
|
||||
|
|
@ -82,10 +109,9 @@
|
|||
saving = true;
|
||||
try {
|
||||
await updateSegment(accessToken, seg.id, trimmed);
|
||||
// Oppdater lokalt
|
||||
seg.content = trimmed;
|
||||
seg.edited = true;
|
||||
segments = [...segments]; // trigger reactivity
|
||||
segments = [...segments];
|
||||
cancelEditing();
|
||||
} catch (e) {
|
||||
console.error('Kunne ikke lagre segment:', e);
|
||||
|
|
@ -102,6 +128,50 @@
|
|||
cancelEditing();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRetranscribe() {
|
||||
retranscribing = true;
|
||||
error = null;
|
||||
try {
|
||||
await retranscribe(accessToken, nodeId);
|
||||
error = null;
|
||||
// Vis melding — jobben kjører asynkront
|
||||
retranscribing = false;
|
||||
// Poll for nye versjoner etter en stund
|
||||
pollForNewVersion();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Kunne ikke starte re-transkripsjon';
|
||||
retranscribing = false;
|
||||
}
|
||||
}
|
||||
|
||||
let polling = $state(false);
|
||||
|
||||
async function pollForNewVersion() {
|
||||
polling = true;
|
||||
const currentVersionCount = versions.length;
|
||||
const maxAttempts = 60; // 5 minutter med 5-sekunders intervall
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
try {
|
||||
const resp = await fetchTranscriptionVersions(accessToken, nodeId);
|
||||
if (resp.versions.length > currentVersionCount) {
|
||||
// Ny versjon er klar — last inn på nytt
|
||||
await loadSegments(nodeId, accessToken);
|
||||
polling = false;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Ignorer feil under polling
|
||||
}
|
||||
}
|
||||
polling = false;
|
||||
}
|
||||
|
||||
function handleComparisonDone() {
|
||||
showCompare = false;
|
||||
loadSegments(nodeId, accessToken);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
|
|
@ -110,7 +180,72 @@
|
|||
<p class="text-[10px] text-red-400 px-1">{error}</p>
|
||||
{:else if segments.length === 0}
|
||||
<p class="text-[10px] text-gray-400 px-1">Ingen segmenter</p>
|
||||
{:else if showCompare && versions.length >= 2}
|
||||
<!-- Sammenligningsvisning -->
|
||||
<RetranscriptionCompare
|
||||
{nodeId}
|
||||
{accessToken}
|
||||
newVersion={versions[0].transcribed_at}
|
||||
oldVersion={versions[1].transcribed_at}
|
||||
ondone={handleComparisonDone}
|
||||
{onseek}
|
||||
{currentTime}
|
||||
/>
|
||||
{:else}
|
||||
<!-- Handlingsknapper -->
|
||||
<div class="flex items-center justify-between px-1 mb-1">
|
||||
<span class="text-[10px] text-gray-400">
|
||||
{segments.length} segmenter
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
{#if polling}
|
||||
<span class="text-[10px] text-blue-500 flex items-center gap-0.5">
|
||||
<svg class="h-3 w-3 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
></path>
|
||||
</svg>
|
||||
Transkriberer...
|
||||
</span>
|
||||
{:else if versions.length >= 2}
|
||||
<button
|
||||
onclick={() => {
|
||||
showCompare = true;
|
||||
}}
|
||||
class="text-[10px] text-blue-500 hover:text-blue-700"
|
||||
>
|
||||
Sammenlign versjoner
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={handleRetranscribe}
|
||||
disabled={retranscribing || polling}
|
||||
class="text-[10px] text-gray-400 hover:text-blue-600 disabled:text-gray-300 flex items-center gap-0.5"
|
||||
title="Kjør ny transkripsjon"
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
Re-transkriber
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Segment-liste -->
|
||||
<div class="max-h-64 overflow-y-auto space-y-0.5">
|
||||
{#each segments as seg (seg.id)}
|
||||
{@const isActive = activeSegmentId === seg.id}
|
||||
|
|
@ -170,7 +305,9 @@
|
|||
>
|
||||
{seg.content}
|
||||
{#if seg.edited}
|
||||
<span class="text-[9px] text-amber-500 ml-1" title="Manuelt redigert">redigert</span>
|
||||
<span class="text-[9px] text-amber-500 ml-1" title="Manuelt redigert"
|
||||
>redigert</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1450,3 +1450,262 @@ pub async fn update_segment(
|
|||
edited: true,
|
||||
}))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// POST /intentions/retranscribe — trigger re-transkripsjon for eksisterende media-node
|
||||
// =============================================================================
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RetranscribeRequest {
|
||||
/// Media-node-ID å re-transkribere.
|
||||
pub node_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct RetranscribeResponse {
|
||||
pub job_id: Uuid,
|
||||
}
|
||||
|
||||
/// POST /intentions/retranscribe
|
||||
///
|
||||
/// Trigger en ny transkripsjons-jobb for en eksisterende media-node.
|
||||
/// Henter CAS-hash og MIME fra nodens metadata. Krever skrivetilgang.
|
||||
pub async fn retranscribe(
|
||||
State(state): State<AppState>,
|
||||
user: AuthUser,
|
||||
Json(req): Json<RetranscribeRequest>,
|
||||
) -> Result<Json<RetranscribeResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
// Verifiser skrivetilgang
|
||||
let can_modify = user_can_modify_node(&state.db, user.node_id, req.node_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Tilgangssjekk feilet");
|
||||
internal_error("Databasefeil ved tilgangssjekk")
|
||||
})?;
|
||||
|
||||
if !can_modify {
|
||||
return Err(forbidden("Ikke tilgang til å re-transkribere denne noden"));
|
||||
}
|
||||
|
||||
// Hent metadata for CAS-hash og MIME
|
||||
let node: Option<(serde_json::Value,)> = sqlx::query_as(
|
||||
"SELECT metadata FROM nodes WHERE id = $1 AND node_kind = 'media'",
|
||||
)
|
||||
.bind(req.node_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Feil ved henting av node");
|
||||
internal_error("Databasefeil")
|
||||
})?;
|
||||
|
||||
let Some((metadata,)) = node else {
|
||||
return Err(bad_request("Noden finnes ikke eller er ikke en media-node"));
|
||||
};
|
||||
|
||||
let cas_hash = metadata["cas_hash"]
|
||||
.as_str()
|
||||
.ok_or_else(|| bad_request("Noden mangler cas_hash i metadata"))?;
|
||||
let mime = metadata["mime"]
|
||||
.as_str()
|
||||
.unwrap_or("audio/mpeg");
|
||||
|
||||
// Finn collection fra eier-kjede
|
||||
let collection_id = find_collection_for_node(&state.db, req.node_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"media_node_id": req.node_id,
|
||||
"cas_hash": cas_hash,
|
||||
"mime": mime,
|
||||
"language": "no",
|
||||
});
|
||||
|
||||
let job_id = crate::jobs::enqueue(&state.db, "whisper_transcribe", payload, collection_id, 5)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Kunne ikke opprette re-transkripsjons-jobb");
|
||||
internal_error("Kunne ikke starte re-transkripsjon")
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
job_id = %job_id,
|
||||
node_id = %req.node_id,
|
||||
user = %user.node_id,
|
||||
"Re-transkripsjons-jobb opprettet"
|
||||
);
|
||||
|
||||
Ok(Json(RetranscribeResponse { job_id }))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// POST /intentions/resolve_retranscription — anvend brukerens segment-valg
|
||||
// =============================================================================
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SegmentChoice {
|
||||
/// Sekvensnummer i den nye transkripsjonen.
|
||||
pub seq: i32,
|
||||
/// "new" = behold ny versjon, "old" = behold gammel versjon.
|
||||
pub choice: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResolveRetranscriptionRequest {
|
||||
/// Media-node-ID.
|
||||
pub node_id: Uuid,
|
||||
/// `transcribed_at` for den nye versjonen.
|
||||
pub new_version: String,
|
||||
/// `transcribed_at` for den gamle versjonen.
|
||||
pub old_version: String,
|
||||
/// Per-segment-valg.
|
||||
pub choices: Vec<SegmentChoice>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ResolveRetranscriptionResponse {
|
||||
pub resolved: bool,
|
||||
pub kept_old: i32,
|
||||
pub kept_new: i32,
|
||||
}
|
||||
|
||||
/// POST /intentions/resolve_retranscription
|
||||
///
|
||||
/// Anvender brukerens per-segment-valg etter re-transkripsjon.
|
||||
/// For segmenter der brukeren velger "old", kopieres innholdet fra
|
||||
/// den gamle versjonen til den nye. Gamle versjoner slettes etterpå.
|
||||
pub async fn resolve_retranscription(
|
||||
State(state): State<AppState>,
|
||||
user: AuthUser,
|
||||
Json(req): Json<ResolveRetranscriptionRequest>,
|
||||
) -> Result<Json<ResolveRetranscriptionResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
// Verifiser skrivetilgang
|
||||
let can_modify = user_can_modify_node(&state.db, user.node_id, req.node_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Tilgangssjekk feilet");
|
||||
internal_error("Databasefeil ved tilgangssjekk")
|
||||
})?;
|
||||
|
||||
if !can_modify {
|
||||
return Err(forbidden("Ikke tilgang til å endre segmenter"));
|
||||
}
|
||||
|
||||
let new_ts: chrono::DateTime<chrono::Utc> = req.new_version.parse()
|
||||
.map_err(|_| bad_request("Ugyldig new_version-tidsstempel"))?;
|
||||
let old_ts: chrono::DateTime<chrono::Utc> = req.old_version.parse()
|
||||
.map_err(|_| bad_request("Ugyldig old_version-tidsstempel"))?;
|
||||
|
||||
// Hent gamle segmenter (indeksert på seq)
|
||||
let old_segments: Vec<(i32, String, bool)> = sqlx::query_as(
|
||||
"SELECT seq, content, edited FROM transcription_segments WHERE node_id = $1 AND transcribed_at = $2 ORDER BY seq",
|
||||
)
|
||||
.bind(req.node_id)
|
||||
.bind(old_ts)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Feil ved henting av gamle segmenter");
|
||||
internal_error("Databasefeil")
|
||||
})?;
|
||||
|
||||
let old_by_seq: std::collections::HashMap<i32, (String, bool)> = old_segments
|
||||
.into_iter()
|
||||
.map(|(seq, content, edited)| (seq, (content, edited)))
|
||||
.collect();
|
||||
|
||||
let mut kept_old = 0i32;
|
||||
let mut kept_new = 0i32;
|
||||
|
||||
let mut tx = state.db.begin().await.map_err(|e| {
|
||||
tracing::error!(error = %e, "Transaksjon feilet");
|
||||
internal_error("Databasefeil")
|
||||
})?;
|
||||
|
||||
for choice in &req.choices {
|
||||
if choice.choice == "old" {
|
||||
if let Some((old_content, old_edited)) = old_by_seq.get(&choice.seq) {
|
||||
// Kopier gammel tekst til nytt segment, bevar edited-flagg
|
||||
sqlx::query(
|
||||
"UPDATE transcription_segments SET content = $1, edited = $2 WHERE node_id = $3 AND transcribed_at = $4 AND seq = $5",
|
||||
)
|
||||
.bind(old_content)
|
||||
.bind(*old_edited)
|
||||
.bind(req.node_id)
|
||||
.bind(new_ts)
|
||||
.bind(choice.seq)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Feil ved oppdatering av segment");
|
||||
internal_error("Databasefeil ved oppdatering")
|
||||
})?;
|
||||
kept_old += 1;
|
||||
}
|
||||
} else {
|
||||
kept_new += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Slett alle gamle versjoner (ikke bare den valgte — rydd opp)
|
||||
sqlx::query(
|
||||
"DELETE FROM transcription_segments WHERE node_id = $1 AND transcribed_at < $2",
|
||||
)
|
||||
.bind(req.node_id)
|
||||
.bind(new_ts)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Feil ved sletting av gamle segmenter");
|
||||
internal_error("Databasefeil ved opprydding")
|
||||
})?;
|
||||
|
||||
// Oppdater nodens content med den endelige transkripsjonen
|
||||
let final_segments: Vec<(String,)> = sqlx::query_as(
|
||||
"SELECT content FROM transcription_segments WHERE node_id = $1 AND transcribed_at = $2 ORDER BY seq",
|
||||
)
|
||||
.bind(req.node_id)
|
||||
.bind(new_ts)
|
||||
.fetch_all(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Feil ved henting av endelige segmenter");
|
||||
internal_error("Databasefeil")
|
||||
})?;
|
||||
|
||||
let transcript_text: String = final_segments
|
||||
.iter()
|
||||
.map(|(c,)| c.trim())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
sqlx::query("UPDATE nodes SET content = $1 WHERE id = $2")
|
||||
.bind(&transcript_text)
|
||||
.bind(req.node_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Feil ved oppdatering av node-innhold");
|
||||
internal_error("Databasefeil")
|
||||
})?;
|
||||
|
||||
tx.commit().await.map_err(|e| {
|
||||
tracing::error!(error = %e, "Commit feilet");
|
||||
internal_error("Databasefeil ved commit")
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
node_id = %req.node_id,
|
||||
kept_old = kept_old,
|
||||
kept_new = kept_new,
|
||||
"Re-transkripsjon løst"
|
||||
);
|
||||
|
||||
Ok(Json(ResolveRetranscriptionResponse {
|
||||
resolved: true,
|
||||
kept_old,
|
||||
kept_new,
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -141,6 +141,10 @@ async fn main() {
|
|||
.route("/query/nodes", get(queries::query_nodes))
|
||||
.route("/query/segments", get(queries::query_segments))
|
||||
.route("/intentions/update_segment", post(intentions::update_segment))
|
||||
.route("/intentions/retranscribe", post(intentions::retranscribe))
|
||||
.route("/intentions/resolve_retranscription", post(intentions::resolve_retranscription))
|
||||
.route("/query/transcription_versions", get(queries::query_transcription_versions))
|
||||
.route("/query/segments_version", get(queries::query_segments_version))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state);
|
||||
|
||||
|
|
|
|||
|
|
@ -120,6 +120,176 @@ async fn run_query_segments(
|
|||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GET /query/transcription_versions — alle transkripsjonsversjoner for en node
|
||||
// =============================================================================
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct QueryVersionsRequest {
|
||||
pub node_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TranscriptionVersion {
|
||||
pub transcribed_at: String,
|
||||
pub segment_count: i64,
|
||||
pub edited_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct QueryVersionsResponse {
|
||||
pub versions: Vec<TranscriptionVersion>,
|
||||
}
|
||||
|
||||
/// GET /query/transcription_versions?node_id=...
|
||||
///
|
||||
/// Lister alle transkripsjonsversjoner for en node, sortert nyeste først.
|
||||
pub async fn query_transcription_versions(
|
||||
State(state): State<AppState>,
|
||||
user: AuthUser,
|
||||
axum::extract::Query(params): axum::extract::Query<QueryVersionsRequest>,
|
||||
) -> Result<Json<QueryVersionsResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
// Verifiser tilgang
|
||||
let mut tx = state.db.begin().await.map_err(|e| {
|
||||
tracing::error!(error = %e, "Transaksjon feilet");
|
||||
internal_error("Databasefeil")
|
||||
})?;
|
||||
set_rls_context(&mut tx, user.node_id).await.map_err(|e| {
|
||||
tracing::error!(error = %e, "RLS-kontekst feilet");
|
||||
internal_error("Databasefeil")
|
||||
})?;
|
||||
|
||||
let exists = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1)",
|
||||
)
|
||||
.bind(params.node_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Tilgangssjekk feilet");
|
||||
internal_error("Databasefeil")
|
||||
})?;
|
||||
|
||||
tx.commit().await.map_err(|e| {
|
||||
tracing::error!(error = %e, "Commit feilet");
|
||||
internal_error("Databasefeil")
|
||||
})?;
|
||||
|
||||
if !exists {
|
||||
return Ok(Json(QueryVersionsResponse { versions: vec![] }));
|
||||
}
|
||||
|
||||
let rows: Vec<(chrono::DateTime<chrono::Utc>, i64, i64)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT transcribed_at, COUNT(*) as segment_count,
|
||||
COUNT(*) FILTER (WHERE edited) as edited_count
|
||||
FROM transcription_segments
|
||||
WHERE node_id = $1
|
||||
GROUP BY transcribed_at
|
||||
ORDER BY transcribed_at DESC
|
||||
"#,
|
||||
)
|
||||
.bind(params.node_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Feil ved henting av versjoner");
|
||||
internal_error("Databasefeil")
|
||||
})?;
|
||||
|
||||
let versions = rows
|
||||
.into_iter()
|
||||
.map(|(ts, count, edited)| TranscriptionVersion {
|
||||
transcribed_at: ts.to_rfc3339(),
|
||||
segment_count: count,
|
||||
edited_count: edited,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(QueryVersionsResponse { versions }))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GET /query/segments_version — segmenter for en spesifikk versjon
|
||||
// =============================================================================
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct QuerySegmentsVersionRequest {
|
||||
pub node_id: Uuid,
|
||||
pub transcribed_at: String,
|
||||
}
|
||||
|
||||
/// GET /query/segments_version?node_id=...&transcribed_at=...
|
||||
///
|
||||
/// Henter segmenter for en spesifikk transkripsjonsversjon.
|
||||
pub async fn query_segments_version(
|
||||
State(state): State<AppState>,
|
||||
user: AuthUser,
|
||||
axum::extract::Query(params): axum::extract::Query<QuerySegmentsVersionRequest>,
|
||||
) -> Result<Json<QuerySegmentsResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
// Verifiser tilgang
|
||||
let mut tx = state.db.begin().await.map_err(|e| {
|
||||
tracing::error!(error = %e, "Transaksjon feilet");
|
||||
internal_error("Databasefeil")
|
||||
})?;
|
||||
set_rls_context(&mut tx, user.node_id).await.map_err(|e| {
|
||||
tracing::error!(error = %e, "RLS-kontekst feilet");
|
||||
internal_error("Databasefeil")
|
||||
})?;
|
||||
|
||||
let exists = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1)",
|
||||
)
|
||||
.bind(params.node_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Tilgangssjekk feilet");
|
||||
internal_error("Databasefeil")
|
||||
})?;
|
||||
|
||||
tx.commit().await.map_err(|e| {
|
||||
tracing::error!(error = %e, "Commit feilet");
|
||||
internal_error("Databasefeil")
|
||||
})?;
|
||||
|
||||
if !exists {
|
||||
return Ok(Json(QuerySegmentsResponse {
|
||||
segments: vec![],
|
||||
transcribed_at: None,
|
||||
}));
|
||||
}
|
||||
|
||||
let ts: chrono::DateTime<chrono::Utc> = params.transcribed_at.parse()
|
||||
.map_err(|_| {
|
||||
(StatusCode::BAD_REQUEST, Json(ErrorResponse {
|
||||
error: "Ugyldig transcribed_at-tidsstempel".to_string(),
|
||||
}))
|
||||
})?;
|
||||
|
||||
let segments = sqlx::query_as::<_, SegmentResult>(
|
||||
r#"
|
||||
SELECT id, seq, start_ms, end_ms, content, edited
|
||||
FROM transcription_segments
|
||||
WHERE node_id = $1 AND transcribed_at = $2
|
||||
ORDER BY seq
|
||||
"#,
|
||||
)
|
||||
.bind(params.node_id)
|
||||
.bind(ts)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Feil ved henting av segmenter");
|
||||
internal_error("Databasefeil")
|
||||
})?;
|
||||
|
||||
Ok(Json(QuerySegmentsResponse {
|
||||
segments,
|
||||
transcribed_at: Some(ts.to_rfc3339()),
|
||||
}))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// RLS-kontekst
|
||||
// =============================================================================
|
||||
|
|
|
|||
3
tasks.md
3
tasks.md
|
|
@ -99,8 +99,7 @@ Uavhengige faser kan fortsatt plukkes.
|
|||
- [x] 7.4 Lyd-avspilling: spiller av original lyd fra CAS-node. Waveform-visning.
|
||||
- [x] 7.5 Segmenttabell-migrasjon: opprett `transcription_segments`-tabell i PG. Oppdater `transcribe.rs` til SRT-format → parse → skriv segmenter. Miljøvariabler: `WHISPER_MODEL` (default "medium"), `WHISPER_INITIAL_PROMPT`. Ref: `docs/concepts/podcastfabrikken.md` § 3.
|
||||
- [x] 7.6 Transkripsjonsvisning i frontend: segmenter med tidsstempler, avspillingsknapp per segment (hopper til riktig sted i lydfilen), redigerbare tekstfelt (setter `edited = true`). Universell komponent for podcast, møter, voice memos.
|
||||
- [~] 7.7 Re-transkripsjonsflyt: ved ny transkripsjon, vis side-om-side med forrige versjon. Highlight manuelt redigerte segmenter fra forrige versjon. Bruker velger per segment.
|
||||
> Påbegynt: 2026-03-17T18:32
|
||||
- [x] 7.7 Re-transkripsjonsflyt: ved ny transkripsjon, vis side-om-side med forrige versjon. Highlight manuelt redigerte segmenter fra forrige versjon. Bruker velger per segment.
|
||||
- [ ] 7.8 SRT-eksport: generer nedlastbar SRT-fil fra `transcription_segments`-tabellen.
|
||||
|
||||
## Fase 8: Aliaser
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue