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:
parent
f9f3556ece
commit
eb2628c6a1
6 changed files with 3618 additions and 0 deletions
100
maskinrommet/src/highlight.rs
Normal file
100
maskinrommet/src/highlight.rs
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@ use crate::audio;
|
||||||
use crate::cas::CasStore;
|
use crate::cas::CasStore;
|
||||||
use crate::cli_dispatch;
|
use crate::cli_dispatch;
|
||||||
use crate::clip;
|
use crate::clip;
|
||||||
|
use crate::highlight;
|
||||||
use crate::maintenance::MaintenanceState;
|
use crate::maintenance::MaintenanceState;
|
||||||
use crate::pg_writes;
|
use crate::pg_writes;
|
||||||
use crate::publishing::IndexCache;
|
use crate::publishing::IndexCache;
|
||||||
|
|
@ -223,6 +224,10 @@ async fn dispatch(
|
||||||
"clip_url" => {
|
"clip_url" => {
|
||||||
clip::handle_clip_url(job, db).await
|
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" => {
|
"describe_image" => {
|
||||||
crate::describe_image::handle_describe_image(job, db, cas).await
|
crate::describe_image::handle_describe_image(job, db, cas).await
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ mod auth;
|
||||||
pub mod cas;
|
pub mod cas;
|
||||||
pub mod cli_dispatch;
|
pub mod cli_dispatch;
|
||||||
pub mod clip;
|
pub mod clip;
|
||||||
|
pub mod highlight;
|
||||||
mod custom_domain;
|
mod custom_domain;
|
||||||
mod embed_player;
|
mod embed_player;
|
||||||
pub mod describe_image;
|
pub mod describe_image;
|
||||||
|
|
|
||||||
2929
tools/synops-highlight/Cargo.lock
generated
Normal file
2929
tools/synops-highlight/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
21
tools/synops-highlight/Cargo.toml
Normal file
21
tools/synops-highlight/Cargo.toml
Normal 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" }
|
||||||
562
tools/synops-highlight/src/main.rs
Normal file
562
tools/synops-highlight/src/main.rs
Normal 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 på 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue