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:
vegard 2026-03-17 18:41:09 +01:00
parent 7233259d9d
commit b4ee80a97b
8 changed files with 932 additions and 11 deletions

View file

@ -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
});
}

View file

@ -158,8 +158,8 @@
{#if showTranscript}
{#if hasSegments}
<TranscriptionView
{nodeId}
{accessToken}
nodeId={nodeId!}
accessToken={accessToken!}
{currentTime}
onseek={seekTo}
/>

View 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}

View file

@ -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}

View file

@ -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,
}))
}

View file

@ -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);

View file

@ -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
// =============================================================================

View file

@ -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