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 {
|
||||
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">
|
||||
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>
|
||||
|
|
|
|||
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 */
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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("/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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =============================================================================
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue