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
|
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 showTranscript}
|
||||||
{#if hasSegments}
|
{#if hasSegments}
|
||||||
<TranscriptionView
|
<TranscriptionView
|
||||||
{nodeId}
|
nodeId={nodeId!}
|
||||||
{accessToken}
|
accessToken={accessToken!}
|
||||||
{currentTime}
|
{currentTime}
|
||||||
onseek={seekTo}
|
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">
|
<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 {
|
interface Props {
|
||||||
/** Media node ID — brukes for å hente segmenter fra API */
|
/** Media node ID — brukes for å hente segmenter fra API */
|
||||||
|
|
@ -21,7 +29,13 @@
|
||||||
let editText = $state('');
|
let editText = $state('');
|
||||||
let saving = $state(false);
|
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(() => {
|
$effect(() => {
|
||||||
loadSegments(nodeId, accessToken);
|
loadSegments(nodeId, accessToken);
|
||||||
});
|
});
|
||||||
|
|
@ -30,8 +44,21 @@
|
||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
try {
|
try {
|
||||||
const resp = await fetchSegments(token, nid);
|
const [segResp, versResp] = await Promise.all([
|
||||||
segments = resp.segments;
|
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) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'Kunne ikke hente segmenter';
|
error = e instanceof Error ? e.message : 'Kunne ikke hente segmenter';
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -82,10 +109,9 @@
|
||||||
saving = true;
|
saving = true;
|
||||||
try {
|
try {
|
||||||
await updateSegment(accessToken, seg.id, trimmed);
|
await updateSegment(accessToken, seg.id, trimmed);
|
||||||
// Oppdater lokalt
|
|
||||||
seg.content = trimmed;
|
seg.content = trimmed;
|
||||||
seg.edited = true;
|
seg.edited = true;
|
||||||
segments = [...segments]; // trigger reactivity
|
segments = [...segments];
|
||||||
cancelEditing();
|
cancelEditing();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Kunne ikke lagre segment:', e);
|
console.error('Kunne ikke lagre segment:', e);
|
||||||
|
|
@ -102,6 +128,50 @@
|
||||||
cancelEditing();
|
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>
|
</script>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
|
|
@ -110,7 +180,72 @@
|
||||||
<p class="text-[10px] text-red-400 px-1">{error}</p>
|
<p class="text-[10px] text-red-400 px-1">{error}</p>
|
||||||
{:else if segments.length === 0}
|
{:else if segments.length === 0}
|
||||||
<p class="text-[10px] text-gray-400 px-1">Ingen segmenter</p>
|
<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}
|
{: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">
|
<div class="max-h-64 overflow-y-auto space-y-0.5">
|
||||||
{#each segments as seg (seg.id)}
|
{#each segments as seg (seg.id)}
|
||||||
{@const isActive = activeSegmentId === seg.id}
|
{@const isActive = activeSegmentId === seg.id}
|
||||||
|
|
@ -170,7 +305,9 @@
|
||||||
>
|
>
|
||||||
{seg.content}
|
{seg.content}
|
||||||
{#if seg.edited}
|
{#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}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -1450,3 +1450,262 @@ pub async fn update_segment(
|
||||||
edited: true,
|
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/nodes", get(queries::query_nodes))
|
||||||
.route("/query/segments", get(queries::query_segments))
|
.route("/query/segments", get(queries::query_segments))
|
||||||
.route("/intentions/update_segment", post(intentions::update_segment))
|
.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())
|
.layer(TraceLayer::new_for_http())
|
||||||
.with_state(state);
|
.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
|
// 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.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.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.
|
- [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.
|
- [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.
|
||||||
> Påbegynt: 2026-03-17T18:32
|
|
||||||
- [ ] 7.8 SRT-eksport: generer nedlastbar SRT-fil fra `transcription_segments`-tabellen.
|
- [ ] 7.8 SRT-eksport: generer nedlastbar SRT-fil fra `transcription_segments`-tabellen.
|
||||||
|
|
||||||
## Fase 8: Aliaser
|
## Fase 8: Aliaser
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue