Implementer transkripsjonsvisning med segmenter (oppgave 7.6)

Backend:
- GET /query/segments?node_id=... — henter nyeste segmenter for en media-node
  med RLS-basert tilgangssjekk via nodes-tabellen
- POST /intentions/update_segment — redigerer segmenttekst, setter edited=true

Frontend:
- TranscriptionView.svelte: universell komponent for segment-visning med
  tidsstempler, avspillingsknapp per segment, og redigerbare tekstfelt
- AudioPlayer: integrert med TranscriptionView når segmenter finnes,
  faller tilbake til flat tekst ellers
- Mottak og chat-sider oppdatert med nodeId/accessToken for segment-lasting
- Fikser duration_ms → sekunder-konvertering i metadata-oppslag

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-17 18:29:43 +01:00
parent 148e5c222c
commit 0967e43af8
8 changed files with 468 additions and 9 deletions

View file

@ -128,3 +128,50 @@ export async function uploadMedia(
export function casUrl(hash: string): string {
return `${BASE_URL}/cas/${hash}`;
}
// =============================================================================
// Transkripsjons-segmenter
// =============================================================================
export interface Segment {
id: number;
seq: number;
start_ms: number;
end_ms: number;
content: string;
edited: boolean;
}
export interface SegmentsResponse {
segments: Segment[];
transcribed_at: string | null;
}
/** Hent transkripsjons-segmenter for en media-node. */
export async function fetchSegments(
accessToken: string,
nodeId: string
): Promise<SegmentsResponse> {
const res = await fetch(`${BASE_URL}/query/segments?node_id=${encodeURIComponent(nodeId)}`, {
headers: { Authorization: `Bearer ${accessToken}` }
});
if (!res.ok) {
const body = await res.text();
throw new Error(`segments failed (${res.status}): ${body}`);
}
return res.json();
}
/** Oppdater teksten i et transkripsjons-segment. */
export function updateSegment(
accessToken: string,
segmentId: number,
content: string
): Promise<{ segment_id: number; edited: boolean }> {
return post(accessToken, '/intentions/update_segment', {
segment_id: segmentId,
content
});
}

View file

@ -1,19 +1,24 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import WaveSurfer from 'wavesurfer.js';
import TranscriptionView from './TranscriptionView.svelte';
interface Props {
/** CAS URL for the audio file */
src: string;
/** Duration in seconds (from transcription metadata, used as fallback) */
duration?: number;
/** Transcript text to show below the player */
/** Flat transcript text (fallback if no segments available) */
transcript?: string;
/** Compact mode for chat bubbles */
compact?: boolean;
/** Media node ID — enables segment-level transcription view */
nodeId?: string;
/** Access token for fetching segments */
accessToken?: string;
}
let { src, duration, transcript, compact = false }: Props = $props();
let { src, duration, transcript, compact = false, nodeId, accessToken }: Props = $props();
let container: HTMLDivElement | undefined = $state();
let wavesurfer: WaveSurfer | undefined = $state();
@ -25,6 +30,9 @@
let loadError = $state(false);
let showTranscript = $state(false);
/** Indikerer om noden har segment-data (satt via metadata) */
const hasSegments = $derived(!!nodeId && !!accessToken);
function formatTime(seconds: number): string {
if (!seconds || !isFinite(seconds)) return '0:00';
const m = Math.floor(seconds / 60);
@ -32,6 +40,14 @@
return `${m}:${s.toString().padStart(2, '0')}`;
}
function seekTo(timeSeconds: number) {
if (!wavesurfer || !ready) return;
wavesurfer.setTime(timeSeconds);
if (!playing) {
wavesurfer.play();
}
}
onMount(() => {
if (!container) return;
@ -129,7 +145,7 @@
{/if}
<!-- Transcript toggle -->
{#if transcript}
{#if hasSegments || transcript}
<button
onclick={() => { showTranscript = !showTranscript; }}
class="flex items-center gap-1 px-1 text-[10px] text-gray-400 hover:text-gray-600 transition-colors"
@ -140,7 +156,16 @@
Transkripsjon
</button>
{#if showTranscript}
<p class="text-xs text-gray-500 px-1 py-1 bg-gray-50 rounded whitespace-pre-wrap">{transcript}</p>
{#if hasSegments}
<TranscriptionView
{nodeId}
{accessToken}
{currentTime}
onseek={seekTo}
/>
{:else if transcript}
<p class="text-xs text-gray-500 px-1 py-1 bg-gray-50 rounded whitespace-pre-wrap">{transcript}</p>
{/if}
{/if}
{/if}
</div>

View file

@ -0,0 +1,180 @@
<script lang="ts">
import { fetchSegments, updateSegment, type Segment } from '$lib/api';
interface Props {
/** Media node ID — brukes for å hente segmenter fra API */
nodeId: string;
/** Access token for API-kall */
accessToken: string;
/** Nåværende avspillingstid i sekunder (fra AudioPlayer) */
currentTime?: number;
/** Callback for å hoppe til tidspunkt i lydfilen */
onseek?: (timeSeconds: number) => void;
}
let { nodeId, accessToken, currentTime = 0, onseek }: Props = $props();
let segments: Segment[] = $state([]);
let loading = $state(true);
let error = $state<string | null>(null);
let editingId = $state<number | null>(null);
let editText = $state('');
let saving = $state(false);
// Hent segmenter ved mount og når nodeId endres
$effect(() => {
loadSegments(nodeId, accessToken);
});
async function loadSegments(nid: string, token: string) {
loading = true;
error = null;
try {
const resp = await fetchSegments(token, nid);
segments = resp.segments;
} catch (e) {
error = e instanceof Error ? e.message : 'Kunne ikke hente segmenter';
} finally {
loading = false;
}
}
/** Finn aktivt segment basert på nåværende avspillingstid */
const activeSegmentId = $derived.by(() => {
if (!currentTime || segments.length === 0) return null;
const timeMs = currentTime * 1000;
for (const seg of segments) {
if (timeMs >= seg.start_ms && timeMs < seg.end_ms) {
return seg.id;
}
}
return null;
});
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 startEditing(seg: Segment) {
editingId = seg.id;
editText = seg.content;
}
function cancelEditing() {
editingId = null;
editText = '';
}
async function saveEdit(seg: Segment) {
const trimmed = editText.trim();
if (!trimmed || trimmed === seg.content) {
cancelEditing();
return;
}
saving = true;
try {
await updateSegment(accessToken, seg.id, trimmed);
// Oppdater lokalt
seg.content = trimmed;
seg.edited = true;
segments = [...segments]; // trigger reactivity
cancelEditing();
} catch (e) {
console.error('Kunne ikke lagre segment:', e);
} finally {
saving = false;
}
}
function handleKeydown(e: KeyboardEvent, seg: Segment) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
saveEdit(seg);
} else if (e.key === 'Escape') {
cancelEditing();
}
}
</script>
{#if loading}
<p class="text-[10px] text-gray-400 px-1">Laster segmenter...</p>
{:else if error}
<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}
<div class="max-h-64 overflow-y-auto space-y-0.5">
{#each segments as seg (seg.id)}
{@const isActive = activeSegmentId === seg.id}
{@const isEditing = editingId === seg.id}
<div
class="flex items-start gap-1.5 rounded px-1.5 py-1 transition-colors
{isActive ? 'bg-blue-50' : 'hover:bg-gray-50'}"
>
<!-- Tidsstempel + avspillingsknapp -->
<button
onclick={() => handleSeek(seg.start_ms)}
class="shrink-0 mt-0.5 flex items-center gap-0.5 text-[10px] font-mono
{isActive ? 'text-blue-600 font-semibold' : 'text-gray-400 hover:text-blue-500'}
transition-colors"
title="Spill av fra {formatTimestamp(seg.start_ms)}"
>
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
{formatTimestamp(seg.start_ms)}
</button>
<!-- Tekstinnhold -->
{#if isEditing}
<div class="flex-1 min-w-0">
<textarea
bind:value={editText}
onkeydown={(e) => handleKeydown(e, seg)}
disabled={saving}
class="w-full text-xs text-gray-800 bg-white border border-blue-300 rounded px-1.5 py-0.5 resize-none focus:outline-none focus:ring-1 focus:ring-blue-400"
rows={2}
></textarea>
<div class="flex gap-1 mt-0.5">
<button
onclick={() => saveEdit(seg)}
disabled={saving}
class="text-[10px] text-blue-600 hover:text-blue-800 disabled:text-gray-400"
>
{saving ? 'Lagrer...' : 'Lagre'}
</button>
<button
onclick={cancelEditing}
disabled={saving}
class="text-[10px] text-gray-400 hover:text-gray-600"
>
Avbryt
</button>
</div>
</div>
{:else}
<button
onclick={() => startEditing(seg)}
class="flex-1 min-w-0 text-left text-xs leading-relaxed cursor-text
{isActive ? 'text-gray-900' : 'text-gray-600'}
hover:text-gray-900 transition-colors"
title="Klikk for å redigere"
>
{seg.content}
{#if seg.edited}
<span class="text-[9px] text-amber-500 ml-1" title="Manuelt redigert">redigert</span>
{/if}
</button>
{/if}
</div>
{/each}
</div>
{/if}

View file

@ -105,14 +105,15 @@
}
/** Check if a node is an audio media node and extract its metadata */
function audioMeta(node: Node): { src: string; duration?: number } | null {
function audioMeta(node: Node): { src: string; duration?: number; hasSegments: boolean } | null {
if (node.nodeKind !== 'media') return null;
try {
const meta = JSON.parse(node.metadata ?? '{}');
if (typeof meta.mime === 'string' && meta.mime.startsWith('audio/') && meta.cas_hash) {
return {
src: casUrl(meta.cas_hash),
duration: meta.transcription?.duration,
duration: meta.transcription?.duration_ms ? meta.transcription.duration_ms / 1000 : undefined,
hasSegments: (meta.transcription?.segment_count ?? 0) > 0,
};
}
} catch { /* ignore */ }
@ -308,7 +309,9 @@
<AudioPlayer
src={audio.src}
duration={audio.duration}
transcript={vis === 'full' ? (node.content || undefined) : undefined}
transcript={vis === 'full' && !audio.hasSegments ? (node.content || undefined) : undefined}
nodeId={audio.hasSegments ? node.id : undefined}
accessToken={audio.hasSegments ? accessToken : undefined}
/>
</div>
{:else if vis === 'full' && node.content}

View file

@ -159,9 +159,18 @@
function audioDuration(node: Node): number | undefined {
try {
const meta = JSON.parse(node.metadata ?? '{}');
return meta.transcription?.duration;
const dms = meta.transcription?.duration_ms;
return dms ? dms / 1000 : undefined;
} catch { return undefined; }
}
/** Check if an audio node has transcription segments */
function hasSegments(node: Node): boolean {
try {
const meta = JSON.parse(node.metadata ?? '{}');
return (meta.transcription?.segment_count ?? 0) > 0;
} catch { return false; }
}
</script>
<div class="flex h-screen flex-col bg-gray-50">
@ -236,7 +245,9 @@
<AudioPlayer
src={audioSrc(msg)}
duration={audioDuration(msg)}
transcript={msg.content || undefined}
transcript={!hasSegments(msg) ? (msg.content || undefined) : undefined}
nodeId={hasSegments(msg) ? msg.id : undefined}
accessToken={hasSegments(msg) ? accessToken : undefined}
compact
/>
{:else}

View file

@ -1364,3 +1364,89 @@ fn spawn_pg_delete_node(db: PgPool, node_id: Uuid) {
}
});
}
// =============================================================================
// POST /intentions/update_segment — rediger transkripsjons-segment
// =============================================================================
#[derive(Deserialize)]
pub struct UpdateSegmentRequest {
/// Segment-ID (primary key i transcription_segments).
pub segment_id: i64,
/// Ny tekst for segmentet.
pub content: String,
}
#[derive(Serialize)]
pub struct UpdateSegmentResponse {
pub segment_id: i64,
pub edited: bool,
}
/// POST /intentions/update_segment
///
/// Oppdaterer teksten i et transkripsjons-segment og setter `edited = true`.
/// Krever at brukeren har skrivetilgang til noden segmentet tilhører.
pub async fn update_segment(
State(state): State<AppState>,
user: AuthUser,
Json(req): Json<UpdateSegmentRequest>,
) -> Result<Json<UpdateSegmentResponse>, (StatusCode, Json<ErrorResponse>)> {
let content = req.content.trim().to_string();
if content.is_empty() {
return Err(bad_request("Innhold kan ikke være tomt"));
}
// Finn noden dette segmentet tilhører
let segment_node: Option<(Uuid,)> = sqlx::query_as(
"SELECT node_id FROM transcription_segments WHERE id = $1",
)
.bind(req.segment_id)
.fetch_optional(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "Feil ved oppslag av segment");
internal_error("Databasefeil")
})?;
let Some((node_id,)) = segment_node else {
return Err(bad_request("Segment finnes ikke"));
};
// Verifiser skrivetilgang
let can_modify = user_can_modify_node(&state.db, user.node_id, 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 å redigere dette segmentet"));
}
// Oppdater segmentet
sqlx::query(
"UPDATE transcription_segments SET content = $1, edited = true WHERE id = $2",
)
.bind(&content)
.bind(req.segment_id)
.execute(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "Kunne ikke oppdatere segment");
internal_error("Databasefeil ved oppdatering")
})?;
tracing::info!(
segment_id = req.segment_id,
node_id = %node_id,
user = %user.node_id,
"Segment redigert"
);
Ok(Json(UpdateSegmentResponse {
segment_id: req.segment_id,
edited: true,
}))
}

View file

@ -139,6 +139,8 @@ async fn main() {
.route("/intentions/upload_media", post(intentions::upload_media))
.route("/cas/{hash}", get(serving::get_cas_file))
.route("/query/nodes", get(queries::query_nodes))
.route("/query/segments", get(queries::query_segments))
.route("/intentions/update_segment", post(intentions::update_segment))
.layer(TraceLayer::new_for_http())
.with_state(state);

View file

@ -15,6 +15,111 @@ use crate::auth::AuthUser;
use crate::AppState;
use crate::intentions::ErrorResponse;
// =============================================================================
// GET /query/segments — transkripsjons-segmenter for en node
// =============================================================================
#[derive(Deserialize)]
pub struct QuerySegmentsRequest {
/// Node-ID for media-noden.
pub node_id: Uuid,
}
#[derive(Serialize, sqlx::FromRow)]
pub struct SegmentResult {
pub id: i64,
pub seq: i32,
pub start_ms: i32,
pub end_ms: i32,
pub content: String,
pub edited: bool,
}
#[derive(Serialize)]
pub struct QuerySegmentsResponse {
pub segments: Vec<SegmentResult>,
pub transcribed_at: Option<String>,
}
/// GET /query/segments?node_id=...
///
/// Henter nyeste transkripsjons-segmenter for en media-node.
/// Verifiserer tilgang via RLS på nodes-tabellen først.
pub async fn query_segments(
State(state): State<AppState>,
user: AuthUser,
axum::extract::Query(params): axum::extract::Query<QuerySegmentsRequest>,
) -> Result<Json<QuerySegmentsResponse>, (StatusCode, Json<ErrorResponse>)> {
let result = run_query_segments(&state.db, user.node_id, params.node_id).await;
match result {
Ok(resp) => Ok(Json(resp)),
Err(e) => {
tracing::error!(error = %e, "query_segments feilet");
Err(internal_error("Databasefeil ved henting av segmenter"))
}
}
}
async fn run_query_segments(
db: &PgPool,
user_node_id: Uuid,
node_id: Uuid,
) -> Result<QuerySegmentsResponse, sqlx::Error> {
// Verifiser tilgang: sjekk at brukeren kan se noden via RLS
let mut tx = db.begin().await?;
set_rls_context(&mut tx, user_node_id).await?;
let exists = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1)",
)
.bind(node_id)
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
if !exists {
return Ok(QuerySegmentsResponse {
segments: vec![],
transcribed_at: None,
});
}
// Hent nyeste transcribed_at for denne noden
let latest: Option<(chrono::DateTime<chrono::Utc>,)> = sqlx::query_as(
"SELECT transcribed_at FROM transcription_segments WHERE node_id = $1 ORDER BY transcribed_at DESC LIMIT 1",
)
.bind(node_id)
.fetch_optional(db)
.await?;
let Some((transcribed_at,)) = latest else {
return Ok(QuerySegmentsResponse {
segments: vec![],
transcribed_at: None,
});
};
// Hent alle segmenter for nyeste kjøring
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(node_id)
.bind(transcribed_at)
.fetch_all(db)
.await?;
Ok(QuerySegmentsResponse {
segments,
transcribed_at: Some(transcribed_at.to_rfc3339()),
})
}
// =============================================================================
// RLS-kontekst
// =============================================================================