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:
parent
148e5c222c
commit
0967e43af8
8 changed files with 468 additions and 9 deletions
|
|
@ -128,3 +128,50 @@ export async function uploadMedia(
|
||||||
export function casUrl(hash: string): string {
|
export function casUrl(hash: string): string {
|
||||||
return `${BASE_URL}/cas/${hash}`;
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,24 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import WaveSurfer from 'wavesurfer.js';
|
import WaveSurfer from 'wavesurfer.js';
|
||||||
|
import TranscriptionView from './TranscriptionView.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** CAS URL for the audio file */
|
/** CAS URL for the audio file */
|
||||||
src: string;
|
src: string;
|
||||||
/** Duration in seconds (from transcription metadata, used as fallback) */
|
/** Duration in seconds (from transcription metadata, used as fallback) */
|
||||||
duration?: number;
|
duration?: number;
|
||||||
/** Transcript text to show below the player */
|
/** Flat transcript text (fallback if no segments available) */
|
||||||
transcript?: string;
|
transcript?: string;
|
||||||
/** Compact mode for chat bubbles */
|
/** Compact mode for chat bubbles */
|
||||||
compact?: boolean;
|
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 container: HTMLDivElement | undefined = $state();
|
||||||
let wavesurfer: WaveSurfer | undefined = $state();
|
let wavesurfer: WaveSurfer | undefined = $state();
|
||||||
|
|
@ -25,6 +30,9 @@
|
||||||
let loadError = $state(false);
|
let loadError = $state(false);
|
||||||
let showTranscript = $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 {
|
function formatTime(seconds: number): string {
|
||||||
if (!seconds || !isFinite(seconds)) return '0:00';
|
if (!seconds || !isFinite(seconds)) return '0:00';
|
||||||
const m = Math.floor(seconds / 60);
|
const m = Math.floor(seconds / 60);
|
||||||
|
|
@ -32,6 +40,14 @@
|
||||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function seekTo(timeSeconds: number) {
|
||||||
|
if (!wavesurfer || !ready) return;
|
||||||
|
wavesurfer.setTime(timeSeconds);
|
||||||
|
if (!playing) {
|
||||||
|
wavesurfer.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
|
|
@ -129,7 +145,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Transcript toggle -->
|
<!-- Transcript toggle -->
|
||||||
{#if transcript}
|
{#if hasSegments || transcript}
|
||||||
<button
|
<button
|
||||||
onclick={() => { showTranscript = !showTranscript; }}
|
onclick={() => { showTranscript = !showTranscript; }}
|
||||||
class="flex items-center gap-1 px-1 text-[10px] text-gray-400 hover:text-gray-600 transition-colors"
|
class="flex items-center gap-1 px-1 text-[10px] text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
|
|
@ -140,7 +156,16 @@
|
||||||
Transkripsjon
|
Transkripsjon
|
||||||
</button>
|
</button>
|
||||||
{#if showTranscript}
|
{#if showTranscript}
|
||||||
|
{#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>
|
<p class="text-xs text-gray-500 px-1 py-1 bg-gray-50 rounded whitespace-pre-wrap">{transcript}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
180
frontend/src/lib/components/TranscriptionView.svelte
Normal file
180
frontend/src/lib/components/TranscriptionView.svelte
Normal 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}
|
||||||
|
|
@ -105,14 +105,15 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check if a node is an audio media node and extract its metadata */
|
/** 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;
|
if (node.nodeKind !== 'media') return null;
|
||||||
try {
|
try {
|
||||||
const meta = JSON.parse(node.metadata ?? '{}');
|
const meta = JSON.parse(node.metadata ?? '{}');
|
||||||
if (typeof meta.mime === 'string' && meta.mime.startsWith('audio/') && meta.cas_hash) {
|
if (typeof meta.mime === 'string' && meta.mime.startsWith('audio/') && meta.cas_hash) {
|
||||||
return {
|
return {
|
||||||
src: casUrl(meta.cas_hash),
|
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 */ }
|
} catch { /* ignore */ }
|
||||||
|
|
@ -308,7 +309,9 @@
|
||||||
<AudioPlayer
|
<AudioPlayer
|
||||||
src={audio.src}
|
src={audio.src}
|
||||||
duration={audio.duration}
|
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>
|
</div>
|
||||||
{:else if vis === 'full' && node.content}
|
{:else if vis === 'full' && node.content}
|
||||||
|
|
|
||||||
|
|
@ -159,9 +159,18 @@
|
||||||
function audioDuration(node: Node): number | undefined {
|
function audioDuration(node: Node): number | undefined {
|
||||||
try {
|
try {
|
||||||
const meta = JSON.parse(node.metadata ?? '{}');
|
const meta = JSON.parse(node.metadata ?? '{}');
|
||||||
return meta.transcription?.duration;
|
const dms = meta.transcription?.duration_ms;
|
||||||
|
return dms ? dms / 1000 : undefined;
|
||||||
} catch { return 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>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-screen flex-col bg-gray-50">
|
<div class="flex h-screen flex-col bg-gray-50">
|
||||||
|
|
@ -236,7 +245,9 @@
|
||||||
<AudioPlayer
|
<AudioPlayer
|
||||||
src={audioSrc(msg)}
|
src={audioSrc(msg)}
|
||||||
duration={audioDuration(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
|
compact
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,8 @@ async fn main() {
|
||||||
.route("/intentions/upload_media", post(intentions::upload_media))
|
.route("/intentions/upload_media", post(intentions::upload_media))
|
||||||
.route("/cas/{hash}", get(serving::get_cas_file))
|
.route("/cas/{hash}", get(serving::get_cas_file))
|
||||||
.route("/query/nodes", get(queries::query_nodes))
|
.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())
|
.layer(TraceLayer::new_for_http())
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,111 @@ use crate::auth::AuthUser;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use crate::intentions::ErrorResponse;
|
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
|
// RLS-kontekst
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue