Auto Highlight Reel: AI-kuratert klipp-pakke fra podcast-transkripsjon

Ny feature: highlight_extract-jobb som analyserer fullstendig
transkripsjon etter innspilling og finner 5-10 klippverdige øyeblikk
(humor, emosjon, sterke meninger, punchlines, narrative høydepunkter).

Komponenter:
- synops-highlight CLI: henter segmenter, kaller AI, oppretter klipp-noder
- maskinrommet/highlight.rs: jobbdispatcher med modellrouting
- Registrert i jobbkø-dispatcher som "highlight_extract"

Hvert klipp blir en content-node med metadata (tidsstempler, score,
foreslått teksting, thumbnail-sitat, hashtags) og derived_from-edge
til episoden. Bruker synops/high-modell via AI Gateway.

Ref: docs/proposals/auto_highlight_reel.md
This commit is contained in:
vegard 2026-03-19 21:40:50 +00:00
parent f9f3556ece
commit eb2628c6a1
6 changed files with 3618 additions and 0 deletions

View file

@ -0,0 +1,100 @@
// Highlight-reel dispatcher — delegerer til synops-highlight CLI.
//
// Maskinrommet orkestrerer, CLI-verktøyet gjør jobben:
// henter transkripsjon, kaller AI for analyse, oppretter klipp-noder.
//
// Jobbtype: "highlight_extract"
// Payload: { "media_node_id": "<uuid>", "requested_by": "<uuid>",
// "collection_id": "<uuid>" (valgfri) }
//
// Ref: docs/proposals/auto_highlight_reel.md
// docs/retninger/unix_filosofi.md
use uuid::Uuid;
use crate::ai_admin;
use crate::cli_dispatch;
use crate::jobs::JobRow;
/// Synops-highlight binary path.
fn highlight_bin() -> String {
std::env::var("SYNOPS_HIGHLIGHT_BIN")
.unwrap_or_else(|_| "synops-highlight".to_string())
}
/// Handler for highlight_extract-jobber.
///
/// Spawner synops-highlight med --write for å gjøre alt arbeidet:
/// transkripsjonshenting, AI-analyse, klipp-node-opprettelse.
///
/// Payload forventer:
/// - media_node_id: UUID — episodenoden med transkripsjon
/// - requested_by: UUID — brukeren som utløste highlight-analysen
/// - collection_id: UUID (valgfri) — podcast-samling for belongs_to-edge
pub async fn handle_highlight_extract(
job: &JobRow,
db: &sqlx::PgPool,
) -> Result<serde_json::Value, String> {
let media_node_id: Uuid = job
.payload
.get("media_node_id")
.and_then(|v| v.as_str())
.and_then(|s| s.parse().ok())
.ok_or("Mangler gyldig media_node_id i payload")?;
let requested_by: Uuid = job
.payload
.get("requested_by")
.and_then(|v| v.as_str())
.and_then(|s| s.parse().ok())
.ok_or("Mangler gyldig requested_by i payload")?;
let collection_id: Option<Uuid> = job
.payload
.get("collection_id")
.and_then(|v| v.as_str())
.and_then(|s| s.parse().ok());
// Bygg kommando
let bin = highlight_bin();
let mut cmd = tokio::process::Command::new(&bin);
cmd.arg("--media-node-id")
.arg(media_node_id.to_string())
.arg("--requested-by")
.arg(requested_by.to_string())
.arg("--write");
if let Some(coll_id) = collection_id {
cmd.arg("--collection-id").arg(coll_id.to_string());
}
// Sett miljøvariabler CLI-verktøyet trenger
cli_dispatch::set_database_url(&mut cmd)?;
cli_dispatch::forward_env(&mut cmd, "AI_GATEWAY_URL");
cli_dispatch::forward_env(&mut cmd, "LITELLM_MASTER_KEY");
// Modellalias fra ai_job_routing — admin kan endre uten redeploy
let model_alias = ai_admin::resolve_routing_or_default(db, "highlight").await;
cmd.env("AI_HIGHLIGHT_MODEL", &model_alias);
tracing::info!(
media_node_id = %media_node_id,
requested_by = %requested_by,
collection_id = ?collection_id,
bin = %bin,
"Starter synops-highlight"
);
let result = cli_dispatch::run_cli_tool(&bin, &mut cmd).await?;
tracing::info!(
media_node_id = %media_node_id,
clips_created = result["clips_created"].as_u64().unwrap_or(0),
highlights_found = result["highlights_found"].as_u64().unwrap_or(0),
status = result["status"].as_str().unwrap_or("unknown"),
"synops-highlight fullført"
);
Ok(result)
}

View file

@ -21,6 +21,7 @@ use crate::audio;
use crate::cas::CasStore;
use crate::cli_dispatch;
use crate::clip;
use crate::highlight;
use crate::maintenance::MaintenanceState;
use crate::pg_writes;
use crate::publishing::IndexCache;
@ -223,6 +224,10 @@ async fn dispatch(
"clip_url" => {
clip::handle_clip_url(job, db).await
}
// Highlight-reel: AI-kuraterte klipp fra fullstendig transkripsjon
"highlight_extract" => {
highlight::handle_highlight_extract(job, db).await
}
"describe_image" => {
crate::describe_image::handle_describe_image(job, db, cas).await
}

View file

@ -13,6 +13,7 @@ mod auth;
pub mod cas;
pub mod cli_dispatch;
pub mod clip;
pub mod highlight;
mod custom_domain;
mod embed_player;
pub mod describe_image;

2929
tools/synops-highlight/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,21 @@
[package]
name = "synops-highlight"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "synops-highlight"
path = "src/main.rs"
[dependencies]
clap = { version = "4", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v7", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
synops-common = { path = "../synops-common" }

View file

@ -0,0 +1,562 @@
// synops-highlight — AI-kuratert highlight reel fra podcast-transkripsjon.
//
// Analyserer fullstendig transkripsjon etter innspilling og finner
// 5-10 klippverdige øyeblikk: humor, emosjonelle topper, sterke meninger,
// punchlines og narrative høydepunkter.
//
// Input: --media-node-id <uuid> (episodenode med transkripsjon)
// Output: JSON med foreslåtte klipp (tidsstempler, teksting, hashtags, score)
// Med --write: oppretter klipp-noder og edges i PG.
//
// Miljøvariabler:
// DATABASE_URL — PostgreSQL-tilkobling (påkrevd)
// AI_GATEWAY_URL — LiteLLM gateway (default: http://localhost:4000)
// LITELLM_MASTER_KEY — API-nøkkel for LiteLLM
// AI_HIGHLIGHT_MODEL — Modellalias (default: synops/high)
//
// Ref: docs/proposals/auto_highlight_reel.md
// docs/retninger/unix_filosofi.md
use clap::Parser;
use serde::{Deserialize, Serialize};
use std::process;
use uuid::Uuid;
/// AI-kuratert highlight reel fra podcast-transkripsjon.
#[derive(Parser)]
#[command(name = "synops-highlight", about = "Generer highlight reel fra transkripsjon")]
struct Cli {
/// Media-node-ID (episode med transkripsjon)
#[arg(long)]
media_node_id: Uuid,
/// Bruker-ID som utløste analysen
#[arg(long)]
requested_by: Option<Uuid>,
/// Podcast-samling (for belongs_to-edge)
#[arg(long)]
collection_id: Option<Uuid>,
/// Skriv klipp-noder og edges til database
#[arg(long)]
write: bool,
}
// --- Database-rader ---
#[derive(sqlx::FromRow)]
struct MediaNode {
title: Option<String>,
#[allow(dead_code)]
content: Option<String>,
created_by: Option<Uuid>,
#[allow(dead_code)]
metadata: serde_json::Value,
}
#[derive(sqlx::FromRow)]
struct TranscriptSegment {
#[allow(dead_code)]
seq: i32,
start_ms: i32,
end_ms: i32,
content: String,
}
// --- LLM request/response (OpenAI-kompatibel) ---
#[derive(Serialize)]
struct ChatRequest {
model: String,
messages: Vec<ChatMessage>,
temperature: f32,
response_format: ResponseFormat,
}
#[derive(Serialize)]
struct ResponseFormat {
r#type: String,
}
#[derive(Serialize)]
struct ChatMessage {
role: String,
content: String,
}
#[derive(Deserialize)]
struct ChatResponse {
choices: Vec<Choice>,
#[serde(default)]
usage: Option<UsageInfo>,
#[serde(default)]
model: Option<String>,
}
#[derive(Deserialize, Clone)]
struct UsageInfo {
#[serde(default)]
prompt_tokens: i64,
#[serde(default)]
completion_tokens: i64,
}
#[derive(Deserialize)]
struct Choice {
message: MessageContent,
}
#[derive(Deserialize)]
struct MessageContent {
content: Option<String>,
}
// --- AI-analysens output ---
#[derive(Deserialize, Debug)]
struct HighlightResponse {
#[serde(default)]
highlights: Vec<Highlight>,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
struct Highlight {
/// Starttid i millisekunder
start_ms: i64,
/// Sluttid i millisekunder
end_ms: i64,
/// Klippverdighets-score 0.0-1.0
score: f64,
/// Kategori: humor, emotion, opinion, punchline, narrative
reason: String,
/// Foreslått teksting for sosiale medier
suggested_caption: String,
/// Det sterkeste sitatet (thumbnail-tekst)
quote: String,
/// Foreslåtte hashtags
#[serde(default)]
hashtags: Vec<String>,
}
const SYSTEM_PROMPT: &str = r#"Du er en podcast-produsent som identifiserer de beste øyeblikkene i en podcast-episode. Analyser transkripsjonen og finn 5-10 klippverdige øyeblikk (15-45 sekunder hver).
For hvert høydepunkt, vurder:
- **humor**: Morsomme øyeblikk, vitser, latter-øyeblikk
- **emotion**: Emosjonelle topper, sårbare øyeblikk, sterke reaksjoner
- **opinion**: Kontroversielle eller sterke meninger som engasjerer
- **punchline**: Slagkraftige formuleringer, one-liners, quotable moments
- **narrative**: Narrative vendepunkter, overraskende avsløringer
Returner KUN et JSON-objekt med denne strukturen:
{
"highlights": [
{
"start_ms": 12000,
"end_ms": 45000,
"score": 0.92,
"reason": "punchline",
"suggested_caption": "Kort, engasjerende tekst for sosiale medier (maks 280 tegn)",
"quote": "Det sterkeste sitatet fra klippet",
"hashtags": ["podcast", "tema1", "tema2"]
}
]
}
Regler:
- Hvert klipp bør være 15-45 sekunder (15000-45000ms)
- Sorter etter score (høyeste først)
- Bruk start_ms/end_ms fra transkripsjonen ikke dikt opp tidsstempler
- suggested_caption skal være catchy og fungere TikTok/Instagram/Twitter
- quote skal være et direkte sitat som fungerer som thumbnail-tekst
- hashtags: 3-5 relevante, norske hashtags per klipp
- Returner tom highlights-liste hvis innholdet ikke har klippverdige øyeblikk
- Foretrekk øyeblikk som fungerer alene uten kontekst"#;
#[tokio::main]
async fn main() {
synops_common::logging::init("synops_highlight");
let cli = Cli::parse();
if cli.write && cli.requested_by.is_none() {
eprintln!("Feil: --requested-by er påkrevd sammen med --write");
process::exit(1);
}
if let Err(e) = run(cli).await {
eprintln!("Feil: {e}");
process::exit(1);
}
}
async fn run(cli: Cli) -> Result<(), String> {
let db = synops_common::db::connect().await?;
let media_node_id = cli.media_node_id;
// 1. Hent medianode
let media = sqlx::query_as::<_, MediaNode>(
"SELECT title, content, created_by, metadata FROM nodes WHERE id = $1",
)
.bind(media_node_id)
.fetch_optional(&db)
.await
.map_err(|e| format!("PG-feil ved henting av node: {e}"))?
.ok_or_else(|| format!("Node {media_node_id} finnes ikke"))?;
// 2. Hent transkripsjonssegmenter
let segments = sqlx::query_as::<_, TranscriptSegment>(
r#"SELECT seq, start_ms, end_ms, content
FROM transcription_segments
WHERE node_id = $1
ORDER BY seq ASC"#,
)
.bind(media_node_id)
.fetch_all(&db)
.await
.map_err(|e| format!("PG-feil ved henting av segmenter: {e}"))?;
if segments.is_empty() {
let result = serde_json::json!({
"status": "skipped",
"reason": "no_transcription",
"media_node_id": media_node_id.to_string()
});
println!("{}", serde_json::to_string_pretty(&result).unwrap());
return Ok(());
}
tracing::info!(
media_node_id = %media_node_id,
segments = segments.len(),
"Hentet transkripsjon, sender til AI for highlight-analyse"
);
// 3. Bygg transkripsjon med tidsstempler for AI
let transcript = build_timestamped_transcript(&segments);
let episode_title = media.title.unwrap_or_else(|| "Ukjent episode".to_string());
let user_content = format!(
"Episode: {episode_title}\n\nTranskripsjon med tidsstempler (ms):\n\n{transcript}"
);
// 4. Kall LLM
let (highlights_resp, llm_usage, llm_model) = call_llm(&user_content).await?;
let highlights = highlights_resp.highlights;
tracing::info!(
media_node_id = %media_node_id,
highlights_found = highlights.len(),
"AI-analyse fullført"
);
if highlights.is_empty() {
let result = serde_json::json!({
"status": "completed",
"media_node_id": media_node_id.to_string(),
"highlights_found": 0,
"clips_created": 0,
"reason": "no_highlights_found"
});
println!("{}", serde_json::to_string_pretty(&result).unwrap());
return Ok(());
}
// 5. Validér og filtrer highlights
let last_segment_end = segments.last().map(|s| s.end_ms as i64).unwrap_or(0);
let valid_highlights: Vec<&Highlight> = highlights
.iter()
.filter(|h| {
h.start_ms >= 0
&& h.end_ms > h.start_ms
&& h.end_ms <= last_segment_end
&& (h.end_ms - h.start_ms) >= 10_000 // minst 10 sek
&& (h.end_ms - h.start_ms) <= 60_000 // maks 60 sek
&& h.score >= 0.0
&& h.score <= 1.0
})
.collect();
tracing::info!(
valid = valid_highlights.len(),
total = highlights.len(),
"Highlights validert"
);
let tokens_in = llm_usage.as_ref().map(|u| u.prompt_tokens).unwrap_or(0);
let tokens_out = llm_usage.as_ref().map(|u| u.completion_tokens).unwrap_or(0);
let model_id = llm_model.unwrap_or_else(|| "unknown".to_string());
// 6. Skriv til database eller bare output
let result = if cli.write {
let requested_by = cli.requested_by.unwrap();
let created_by = media.created_by.unwrap_or(media_node_id);
let collection_id = cli.collection_id;
let clips_created = write_highlights(
&db,
media_node_id,
&valid_highlights,
created_by,
collection_id,
)
.await?;
// Logg AI-ressursforbruk
log_resource_usage(
&db,
media_node_id,
media.created_by,
&model_id,
tokens_in,
tokens_out,
requested_by,
)
.await;
serde_json::json!({
"status": "completed",
"media_node_id": media_node_id.to_string(),
"highlights_found": highlights.len(),
"highlights_valid": valid_highlights.len(),
"clips_created": clips_created,
"model": model_id,
"tokens_in": tokens_in,
"tokens_out": tokens_out,
})
} else {
let highlight_data: Vec<&Highlight> = valid_highlights.iter().copied().collect();
serde_json::json!({
"status": "completed",
"media_node_id": media_node_id.to_string(),
"highlights_found": highlights.len(),
"highlights_valid": valid_highlights.len(),
"highlights": highlight_data,
"model": model_id,
"tokens_in": tokens_in,
"tokens_out": tokens_out,
})
};
println!(
"{}",
serde_json::to_string_pretty(&result)
.map_err(|e| format!("JSON-serialisering feilet: {e}"))?
);
Ok(())
}
/// Bygg en tidsstemplet transkripsjon for AI-analyse.
/// Format: [12000-15000ms] Tekst her...
fn build_timestamped_transcript(segments: &[TranscriptSegment]) -> String {
let mut lines = Vec::with_capacity(segments.len());
for seg in segments {
lines.push(format!("[{}-{}ms] {}", seg.start_ms, seg.end_ms, seg.content));
}
lines.join("\n")
}
/// Kall LiteLLM for highlight-analyse.
async fn call_llm(
user_content: &str,
) -> Result<(HighlightResponse, Option<UsageInfo>, Option<String>), String> {
let gateway_url =
std::env::var("AI_GATEWAY_URL").unwrap_or_else(|_| "http://localhost:4000".to_string());
let api_key = std::env::var("LITELLM_MASTER_KEY").unwrap_or_default();
let model =
std::env::var("AI_HIGHLIGHT_MODEL").unwrap_or_else(|_| "synops/high".to_string());
let request = ChatRequest {
model,
messages: vec![
ChatMessage {
role: "system".to_string(),
content: SYSTEM_PROMPT.to_string(),
},
ChatMessage {
role: "user".to_string(),
content: user_content.to_string(),
},
],
temperature: 0.4,
response_format: ResponseFormat {
r#type: "json_object".to_string(),
},
};
let client = reqwest::Client::new();
let url = format!("{gateway_url}/v1/chat/completions");
let resp = client
.post(&url)
.header("Authorization", format!("Bearer {api_key}"))
.header("Content-Type", "application/json")
.json(&request)
.timeout(std::time::Duration::from_secs(120))
.send()
.await
.map_err(|e| format!("LiteLLM-kall feilet: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(format!("LiteLLM returnerte {status}: {body}"));
}
let chat_resp: ChatResponse = resp
.json()
.await
.map_err(|e| format!("Kunne ikke parse LiteLLM-respons: {e}"))?;
let content = chat_resp
.choices
.first()
.and_then(|c| c.message.content.as_deref())
.ok_or("LiteLLM returnerte ingen content")?;
let highlights: HighlightResponse = serde_json::from_str(content)
.map_err(|e| format!("Kunne ikke parse LLM JSON: {e}. Rå output: {content}"))?;
Ok((highlights, chat_resp.usage, chat_resp.model))
}
/// Opprett klipp-noder og edges i PG for godkjente highlights.
/// Returnerer antall klipp opprettet.
async fn write_highlights(
db: &sqlx::PgPool,
media_node_id: Uuid,
highlights: &[&Highlight],
created_by: Uuid,
collection_id: Option<Uuid>,
) -> Result<u32, String> {
let mut clips_created = 0u32;
for highlight in highlights {
let clip_id = Uuid::now_v7();
let clip_metadata = serde_json::json!({
"ai_generated": true,
"clip": {
"start_ms": highlight.start_ms,
"end_ms": highlight.end_ms,
"score": highlight.score,
"reason": highlight.reason,
"suggested_caption": highlight.suggested_caption,
"quote": highlight.quote,
"hashtags": highlight.hashtags,
"source_type": "highlight_reel",
}
});
// Opprett klipp-node
sqlx::query(
r#"INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by)
VALUES ($1, 'content', $2, $3, 'hidden', $4, $5)
ON CONFLICT (id) DO NOTHING"#,
)
.bind(clip_id)
.bind(&highlight.suggested_caption)
.bind(&highlight.quote)
.bind(&clip_metadata)
.bind(created_by)
.execute(db)
.await
.map_err(|e| format!("PG insert clip-node feilet: {e}"))?;
// Edge: klipp → episode (derived_from)
let edge_id = Uuid::now_v7();
sqlx::query(
r#"INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
VALUES ($1, $2, $3, 'derived_from', '{"origin": "highlight_reel"}'::jsonb, true, $4)
ON CONFLICT (source_id, target_id, edge_type) DO NOTHING"#,
)
.bind(edge_id)
.bind(clip_id)
.bind(media_node_id)
.bind(created_by)
.execute(db)
.await
.map_err(|e| format!("PG insert derived_from-edge feilet: {e}"))?;
// Edge: klipp → samling (belongs_to) hvis collection_id er gitt
if let Some(coll_id) = collection_id {
let edge_id = Uuid::now_v7();
sqlx::query(
r#"INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
VALUES ($1, $2, $3, 'belongs_to', '{"origin": "highlight_reel"}'::jsonb, true, $4)
ON CONFLICT (source_id, target_id, edge_type) DO NOTHING"#,
)
.bind(edge_id)
.bind(clip_id)
.bind(coll_id)
.bind(created_by)
.execute(db)
.await
.map_err(|e| format!("PG insert belongs_to-edge feilet: {e}"))?;
}
clips_created += 1;
tracing::info!(
clip_id = %clip_id,
start_ms = highlight.start_ms,
end_ms = highlight.end_ms,
score = highlight.score,
reason = %highlight.reason,
"Highlight-klipp opprettet"
);
}
tracing::info!(
media_node_id = %media_node_id,
clips_created = clips_created,
"Highlights skrevet til database"
);
Ok(clips_created)
}
/// Logg AI-ressursforbruk til resource_usage_log.
async fn log_resource_usage(
db: &sqlx::PgPool,
node_id: Uuid,
_created_by: Option<Uuid>,
model_id: &str,
tokens_in: i64,
tokens_out: i64,
requested_by: Uuid,
) {
let collection_id: Option<Uuid> = sqlx::query_scalar(
"SELECT e.target_id FROM edges e
JOIN nodes n ON n.id = e.target_id
WHERE e.source_id = $1 AND e.edge_type = 'belongs_to' AND n.node_kind = 'collection'
LIMIT 1",
)
.bind(node_id)
.fetch_optional(db)
.await
.ok()
.flatten();
if let Err(e) = sqlx::query(
"INSERT INTO resource_usage_log (target_node_id, triggered_by, collection_id, resource_type, detail)
VALUES ($1, $2, $3, $4, $5)",
)
.bind(node_id)
.bind(Some(requested_by))
.bind(collection_id)
.bind("ai")
.bind(serde_json::json!({
"model_level": "standard",
"model_id": model_id,
"tokens_in": tokens_in,
"tokens_out": tokens_out,
"job_type": "highlight_extract"
}))
.execute(db)
.await
{
tracing::warn!(error = %e, "Kunne ikke logge AI-ressursforbruk");
}
}