// synops-ai — LLM-verktøy for Synops-plattformen. // // To moduser: // 1. `prompt`: Direkte LLM-kall via LiteLLM — prompt inn, tekst ut. // 2. `script`: AI-assistert oppretting av orkestreringsscript (oppgave 24.7). // // 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 use clap::{Parser, Subcommand}; use serde::{Deserialize, Serialize}; use std::process; use uuid::Uuid; /// synops-ai — LLM-verktøy for Synops #[derive(Parser)] #[command(name = "synops-ai")] struct Cli { #[command(subcommand)] command: Command, } #[derive(Subcommand)] enum Command { /// Direkte LLM-kall: prompt inn, tekst ut Prompt(PromptArgs), /// Generer orkestreringsscript fra fritekst-beskrivelse Script(ScriptArgs), } #[derive(Parser)] struct PromptArgs { /// Bruker-prompt (påkrevd) #[arg(long)] prompt: String, /// Modellalias (f.eks. "sidelinja/rutine"). Hvis utelatt, slås opp fra ai_job_routing. #[arg(long)] model: Option, /// Systemprompt (valgfri) #[arg(long)] system: Option, /// Bilde-fil å inkludere som visuell kontekst (multimodal/vision). /// Bildet sendes som base64 data-URL til LLM. #[arg(long)] image: Option, /// MIME-type for bildet (default: image/png) #[arg(long, default_value = "image/png")] image_mime: String, /// Job-type for modelloppslag og logging (default: "simple_prompt") #[arg(long, default_value = "simple_prompt")] job_type: String, /// Temperatur (0.0–2.0, default: 0.7) #[arg(long, default_value = "0.7")] temperature: f32, /// Samlings-ID for budsjettsjekk og logging #[arg(long)] collection_id: Option, /// Bruker-ID som utløste kallet (for budsjettsjekk og logging) #[arg(long)] user_id: Option, } #[derive(Parser)] struct ScriptArgs { /// Fritekst-beskrivelse av ønsket orkestrering #[arg(long)] description: Option, /// Trigger-event for scriptet (f.eks. "communication.ended") #[arg(long)] trigger_event: Option, /// Trigger-betingelser som JSON #[arg(long)] trigger_conditions: Option, /// Kun skriv ut auto-generert systemprompt (ingen LLM-kall) #[arg(long)] generate_system_prompt: bool, /// Eventually-modus: lagre som work_item #[arg(long)] eventually: bool, /// Bruker-ID som utløste forespørselen (påkrevd for --eventually) #[arg(long)] requested_by: Option, /// Samlings-ID å knytte work_item til #[arg(long)] collection_id: Option, } // --- LLM request/response (OpenAI-kompatibel) --- #[derive(Serialize)] struct ChatRequest { model: String, messages: Vec, temperature: f32, } #[derive(Serialize, Clone)] struct ChatMessage { role: String, content: MessageContent, } /// Meldingsinnhold: enten en enkel streng eller multimodal (tekst + bilde). /// OpenAI-kompatibelt format: string | array<{type, text?, image_url?}> #[derive(Clone)] enum MessageContent { Text(String), Multimodal(Vec), } #[derive(Serialize, Clone)] #[serde(tag = "type")] enum ContentPart { #[serde(rename = "text")] Text { text: String }, #[serde(rename = "image_url")] ImageUrl { image_url: ImageUrlDetail }, } #[derive(Serialize, Clone)] struct ImageUrlDetail { url: String, #[serde(skip_serializing_if = "Option::is_none")] detail: Option, } impl Serialize for MessageContent { fn serialize(&self, serializer: S) -> Result { match self { MessageContent::Text(s) => serializer.serialize_str(s), MessageContent::Multimodal(parts) => parts.serialize(serializer), } } } #[derive(Deserialize)] struct ChatResponse { choices: Vec, #[serde(default)] usage: Option, #[serde(default)] model: Option, } #[derive(Deserialize, Clone)] struct UsageInfo { #[serde(default)] prompt_tokens: i64, #[serde(default)] completion_tokens: i64, } #[derive(Deserialize)] struct Choice { message: ResponseMessage, } #[derive(Deserialize)] struct ResponseMessage { content: Option, } #[tokio::main] async fn main() { synops_common::logging::init("synops_ai"); let cli = Cli::parse(); let result = match cli.command { Command::Prompt(args) => run_prompt(args).await, Command::Script(args) => run_script(args).await, }; if let Err(e) = result { eprintln!("Feil: {e}"); process::exit(1); } } // ============================================================ // Prompt-modus (oppgave 28.1): direkte LLM-kall // ============================================================ async fn run_prompt(args: PromptArgs) -> Result<(), String> { let db = synops_common::db::connect().await?; // Bestem modell: eksplisitt flag → ai_job_routing → fallback let model_alias = match &args.model { Some(m) => m.clone(), None => resolve_model(&db, &args.job_type).await?, }; // Sjekk budsjett før LLM-kall if let Some(collection_id) = args.collection_id { if let Err(msg) = check_budget(&db, collection_id, "collection").await { create_budget_work_item(&db, collection_id, args.user_id, &msg).await; return Err(msg); } } if let Some(user_id) = args.user_id { if let Err(msg) = check_budget(&db, user_id, "user").await { let coll = args.collection_id.unwrap_or(user_id); create_budget_work_item(&db, coll, Some(user_id), &msg).await; return Err(msg); } } tracing::info!( model = %model_alias, job_type = %args.job_type, prompt_len = args.prompt.len(), "Sender prompt til LLM" ); // Bygg meldinger let mut messages = Vec::new(); if let Some(ref sys) = args.system { messages.push(ChatMessage { role: "system".to_string(), content: MessageContent::Text(sys.clone()), }); } // Multimodal melding hvis --image er oppgitt let user_content = if let Some(ref image_path) = args.image { let image_data = std::fs::read(image_path) .map_err(|e| format!("Kunne ikke lese bildefil '{}': {e}", image_path))?; use base64::Engine; let b64 = base64::engine::general_purpose::STANDARD.encode(&image_data); let data_url = format!("data:{};base64,{}", args.image_mime, b64); tracing::info!( image_path, image_size = image_data.len(), mime = %args.image_mime, "Inkluderer bilde i multimodal melding" ); MessageContent::Multimodal(vec![ ContentPart::Text { text: args.prompt.clone() }, ContentPart::ImageUrl { image_url: ImageUrlDetail { url: data_url, detail: Some("auto".to_string()), }, }, ]) } else { MessageContent::Text(args.prompt.clone()) }; messages.push(ChatMessage { role: "user".to_string(), content: user_content, }); let (content, usage, actual_model) = call_llm_with(&model_alias, &messages, args.temperature).await?; // Skriv svar til stdout println!("{content}"); // Logg i ai_usage_log (inkludert collection_node_id og requested_by) let tokens_in = usage.as_ref().map(|u| u.prompt_tokens).unwrap_or(0); let tokens_out = usage.as_ref().map(|u| u.completion_tokens).unwrap_or(0); let actual = actual_model.as_deref().unwrap_or("unknown"); if let Err(e) = sqlx::query( "INSERT INTO ai_usage_log (collection_node_id, requested_by, model_alias, model_actual, prompt_tokens, completion_tokens, total_tokens, job_type) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", ) .bind(args.collection_id) .bind(args.user_id) .bind(&model_alias) .bind(actual) .bind(tokens_in as i32) .bind(tokens_out as i32) .bind((tokens_in + tokens_out) as i32) .bind(&args.job_type) .execute(&db) .await { tracing::warn!(error = %e, "Kunne ikke logge AI-bruk i ai_usage_log"); } tracing::info!( model_alias = %model_alias, model_actual = %actual, tokens_in, tokens_out, "LLM-kall fullført" ); Ok(()) } // ============================================================ // Budsjettsjekk (oppgave 28.3) // ============================================================ /// Estimerer kostnad fra tokens (samme formel som maskinrommet/metrics.rs). /// Input: $3/MTok, Output: $15/MTok (konservativt gjennomsnitt). fn estimate_cost_usd(prompt_tokens: i64, completion_tokens: i64) -> f64 { let input_cost = prompt_tokens as f64 * 3.0 / 1_000_000.0; let output_cost = completion_tokens as f64 * 15.0 / 1_000_000.0; ((input_cost + output_cost) * 100.0).round() / 100.0 } /// Sjekker ai_budget for en node (samling eller bruker). /// /// Leser `metadata.ai_budget.monthly_limit_usd` fra noden, /// aggregerer denne månedens forbruk fra ai_usage_log, /// og returnerer Err med feilmelding hvis budsjettet er overskredet. async fn check_budget( db: &sqlx::PgPool, node_id: Uuid, node_type: &str, // "collection" eller "user" ) -> Result<(), String> { // Hent metadata fra noden let metadata: Option = sqlx::query_scalar("SELECT metadata FROM nodes WHERE id = $1") .bind(node_id) .fetch_optional(db) .await .map_err(|e| format!("PG-feil ved budsjettsjekk: {e}"))?; let monthly_limit = metadata .as_ref() .and_then(|m| m.get("ai_budget")) .and_then(|b| b.get("monthly_limit_usd")) .and_then(|v| v.as_f64()); let limit = match monthly_limit { Some(l) => l, None => return Ok(()), // Ingen budsjettgrense satt }; // Aggreger denne månedens forbruk let (total_prompt, total_completion) = match node_type { "collection" => { sqlx::query_as::<_, (i64, i64)>( r#"SELECT COALESCE(SUM(prompt_tokens)::BIGINT, 0), COALESCE(SUM(completion_tokens)::BIGINT, 0) FROM ai_usage_log WHERE collection_node_id = $1 AND created_at >= date_trunc('month', now())"#, ) .bind(node_id) .fetch_one(db) .await .map_err(|e| format!("PG-feil ved aggregering av forbruk: {e}"))? } "user" => { sqlx::query_as::<_, (i64, i64)>( r#"SELECT COALESCE(SUM(prompt_tokens)::BIGINT, 0), COALESCE(SUM(completion_tokens)::BIGINT, 0) FROM ai_usage_log WHERE requested_by = $1 AND created_at >= date_trunc('month', now())"#, ) .bind(node_id) .fetch_one(db) .await .map_err(|e| format!("PG-feil ved aggregering av brukerforbruk: {e}"))? } _ => return Ok(()), }; let current_cost = estimate_cost_usd(total_prompt, total_completion); if current_cost >= limit { let node_title: Option = sqlx::query_scalar("SELECT title FROM nodes WHERE id = $1") .bind(node_id) .fetch_optional(db) .await .ok() .flatten(); let name = node_title.unwrap_or_else(|| node_id.to_string()); return Err(format!( "AI-budsjett overskredet for {node_type} \"{name}\": \ forbruk ${current_cost:.2} >= grense ${limit:.2} denne måneden" )); } tracing::debug!( node_id = %node_id, node_type, current_cost, limit, "Budsjettsjekk OK" ); Ok(()) } /// Oppretter work_item-node når budsjett er overskredet. async fn create_budget_work_item( db: &sqlx::PgPool, collection_id: Uuid, user_id: Option, error_msg: &str, ) { let work_item_id = Uuid::now_v7(); let title = format!("AI-budsjett overskredet"); let created_by = user_id.unwrap_or(collection_id); let metadata = serde_json::json!({ "work_item_type": "budget_exceeded", "collection_id": collection_id.to_string(), "error": error_msg, }); // Opprett work_item-node if let Err(e) = sqlx::query( r#"INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by) VALUES ($1, 'content', $2, $3, 'hidden'::visibility, $4, $5)"#, ) .bind(work_item_id) .bind(&title) .bind(error_msg) .bind(&metadata) .bind(created_by) .execute(db) .await { tracing::warn!(error = %e, "Kunne ikke opprette work_item for budsjettoverskridelse"); return; } // tagged-edge: budget_exceeded let tag_edge_id = Uuid::now_v7(); if let Err(e) = sqlx::query( r#"INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by) VALUES ($1, $2, $2, 'tagged', '{"tag": "budget_exceeded"}'::jsonb, true, $3)"#, ) .bind(tag_edge_id) .bind(work_item_id) .bind(created_by) .execute(db) .await { tracing::warn!(error = %e, "Kunne ikke opprette tagged-edge for work_item"); } // belongs_to-edge til samlingen let belongs_edge_id = Uuid::now_v7(); if let Err(e) = sqlx::query( r#"INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by) VALUES ($1, $2, $3, 'belongs_to', '{}'::jsonb, true, $4)"#, ) .bind(belongs_edge_id) .bind(work_item_id) .bind(collection_id) .bind(created_by) .execute(db) .await { tracing::warn!(error = %e, "Kunne ikke opprette belongs_to-edge for work_item"); } tracing::info!( work_item_id = %work_item_id, collection_id = %collection_id, "Work item opprettet for budsjettoverskridelse" ); } /// Slå opp modellalias fra ai_job_routing. Fallback: sidelinja/rutine. async fn resolve_model(db: &sqlx::PgPool, job_type: &str) -> Result { let row: Option<(String,)> = sqlx::query_as("SELECT alias FROM ai_job_routing WHERE job_type = $1") .bind(job_type) .fetch_optional(db) .await .map_err(|e| format!("PG-feil ved oppslag i ai_job_routing: {e}"))?; match row { Some((alias,)) => { tracing::info!(job_type, alias = %alias, "Modell funnet i ai_job_routing"); Ok(alias) } None => { let fallback = "sidelinja/rutine".to_string(); tracing::info!( job_type, fallback = %fallback, "Ingen routing funnet, bruker fallback" ); Ok(fallback) } } } /// Generisk LLM-kall mot LiteLLM gateway. async fn call_llm_with( model: &str, messages: &[ChatMessage], temperature: f32, ) -> Result<(String, Option, Option), 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 request = ChatRequest { model: model.to_string(), messages: messages.to_vec(), temperature, }; 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")? .to_string(); Ok((content, chat_resp.usage, chat_resp.model)) } // ============================================================ // Script-modus (oppgave 24.7): orkestreringsscript-generering // ============================================================ // --- cli_tool metadata fra PG --- #[derive(sqlx::FromRow)] struct CliToolRow { #[allow(dead_code)] title: Option, metadata: Option, } struct ToolInfo { binary: String, description: String, aliases: Vec, args_hints: Vec<(String, String)>, } fn extract_tool_info(row: &CliToolRow) -> Option { let meta = row.metadata.as_ref()?; let binary = meta.get("binary")?.as_str()?.to_string(); let description = meta .get("description") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let aliases: Vec = meta .get("aliases") .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .filter_map(|v| v.as_str().map(|s| s.to_string())) .collect() }) .unwrap_or_default(); let args_hints: Vec<(String, String)> = meta .get("args_hints") .and_then(|v| v.as_object()) .map(|obj| { obj.iter() .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) .collect() }) .unwrap_or_default(); Some(ToolInfo { binary, description, aliases, args_hints, }) } fn build_system_prompt(tools: &[ToolInfo]) -> String { let mut prompt = String::new(); prompt.push_str( "Du er en orkestreringsplanlegger for Synops, en norsk redaksjonsplattform.\n\ \n\ Du genererer orkestreringsscript i Synops sitt menneskelige scriptspråk.\n\ Scriptet kompileres automatisk til CLI-kall av vaktmesteren.\n\ \n\ VIKTIG: Skriv scriptet i det menneskelige laget — bruk norske verb og \n\ argumenter i parenteser. IKKE skriv CLI-flagg eller teknisk syntax.\n\ \n", ); prompt.push_str("TILGJENGELIGE VERKTØY:\n\n"); for tool in tools { prompt.push_str(&format!("- {}: {}\n", tool.binary, tool.description)); if !tool.aliases.is_empty() { prompt.push_str(&format!(" Verb: {}\n", tool.aliases.join(", "))); } if !tool.args_hints.is_empty() { prompt.push_str(" Argumenter (bruk i parenteser):\n"); for (human, _technical) in &tool.args_hints { prompt.push_str(&format!(" - \"{human}\"\n")); } } prompt.push('\n'); } prompt.push_str( "SCRIPT-GRAMMATIKK (menneskelig lag):\n\ \n\ ```\n\ NÅR \n\ HVIS \n\ \n\ . [(, )]\n\ . \n\ \n\ ved feil: opprett oppgave \"\" ()\n\ ```\n\ \n\ Regler:\n\ - NÅR og HVIS er valgfrie (trigger settes separat i metadata)\n\ - Verb må matche ett av verktøyenes alias-verb\n\ - Argumenter i parentes må matche verktøyets argumenter nøyaktig\n\ - Hvert steg nummereres sekvensielt (1, 2, 3...)\n\ - Steg-spesifikk feilhåndtering med innrykk:\n\ ```\n\ 1. transkriber lydfilen (stor modell)\n\ \x20 ved feil: transkriber lydfilen (medium modell)\n\ ```\n\ - Global feilhåndtering (uten innrykk) på siste linje\n\ - \"opprett oppgave\" er alltid tilgjengelig for feilhåndtering\n\ \n", ); prompt.push_str( "KJENTE TRIGGER-EVENTS:\n\ - node.created — Ny node opprettet\n\ - edge.created — Ny edge opprettet\n\ - communication.ended — Samtale/innspilling avsluttet\n\ - node.published — Node publisert\n\ - scheduled.due — Planlagt tidspunkt nådd\n\ - manual — Bruker trykker \"Kjør\"\n\ \n", ); prompt.push_str( "EKSEMPEL:\n\ Beskrivelse: \"Når en innspilling er ferdig, transkriber og oppsummer\"\n\ \n\ Resultat:\n\ ```\n\ 1. transkriber lydfilen (stor modell)\n\ \x20 ved feil: transkriber lydfilen (medium modell)\n\ 2. oppsummer samtalen\n\ \n\ ved feil: opprett oppgave \"Pipeline feilet\" (bug)\n\ ```\n\ \n\ EKSEMPEL 2:\n\ Beskrivelse: \"Publiser artikkelen og oppdater RSS\"\n\ \n\ Resultat:\n\ ```\n\ 1. render artikkelen\n\ 2. oppdater rss-feed\n\ \n\ ved feil: opprett oppgave \"Publisering feilet\" (bug)\n\ ```\n\ \n", ); prompt.push_str( "INSTRUKSJONER:\n\ 1. Les brukerens beskrivelse nøye.\n\ 2. Velg de riktige verktøyene og argumentene.\n\ 3. Skriv et gyldig script i det menneskelige laget.\n\ 4. Bruk feilhåndtering der det er naturlig (VED_FEIL eller fallback-modell).\n\ 5. Svar KUN med scriptet — ingen forklaringer, ingen markdown-blokker, \n\ ingen kommentarer. Bare rene script-linjer.\n\ 6. Hvis beskrivelsen refererer til funksjonalitet som IKKE finnes i \n\ tilgjengelige verktøy, bruk verbet/objektet likevel — kompilatoren \n\ vil rapportere feilen, og brukeren kan opprette en forespørsel.\n", ); prompt } async fn run_script(args: ScriptArgs) -> Result<(), String> { if !args.generate_system_prompt && args.description.is_none() { return Err("--description eller --generate-system-prompt er påkrevd".into()); } if args.eventually && args.requested_by.is_none() { return Err("--requested-by er påkrevd sammen med --eventually".into()); } let db = synops_common::db::connect().await?; let rows = sqlx::query_as::<_, CliToolRow>( "SELECT title, metadata FROM nodes WHERE node_kind = 'cli_tool' ORDER BY title", ) .fetch_all(&db) .await .map_err(|e| format!("PG-feil ved henting av cli_tool-noder: {e}"))?; let tools: Vec = rows.iter().filter_map(extract_tool_info).collect(); tracing::info!(tool_count = tools.len(), "Hentet cli_tool-noder"); let system_prompt = build_system_prompt(&tools); if args.generate_system_prompt { println!("{system_prompt}"); return Ok(()); } let description = args.description.as_deref().unwrap(); if args.eventually { return save_work_item( &db, description, args.trigger_event.as_deref(), args.trigger_conditions.as_deref(), args.requested_by.unwrap(), args.collection_id, ) .await; } // Synkron script-generering let user_content = build_user_prompt( description, args.trigger_event.as_deref(), args.trigger_conditions.as_deref(), ); tracing::info!(description_len = description.len(), "Sender til LLM for script-generering"); let model = std::env::var("AI_SCRIPT_MODEL").unwrap_or_else(|_| "sidelinja/rutine".to_string()); let messages = vec![ ChatMessage { role: "system".to_string(), content: MessageContent::Text(system_prompt), }, ChatMessage { role: "user".to_string(), content: MessageContent::Text(user_content), }, ]; let (generated_script, llm_usage, llm_model) = call_llm_with(&model, &messages, 0.3).await?; // Strip eventuelle markdown-blokker let generated_script = strip_markdown_fences(&generated_script); tracing::info!(script_len = generated_script.len(), "Script generert fra LLM"); let has_numbered_steps = generated_script.lines().any(|line| { let trimmed = line.trim(); trimmed.len() > 2 && trimmed.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) && trimmed.contains('.') }); 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()); if let Err(e) = sqlx::query( "INSERT INTO resource_usage_log (target_node_id, triggered_by, resource_type, detail) VALUES ($1, $2, $3, $4)", ) .bind(args.collection_id) .bind(args.requested_by) .bind("ai") .bind(serde_json::json!({ "model_id": model_id, "tokens_in": tokens_in, "tokens_out": tokens_out, "job_type": "orchestration_script_generation" })) .execute(&db) .await { tracing::warn!(error = %e, "Kunne ikke logge AI-ressursforbruk"); } let result = serde_json::json!({ "status": "completed", "script": generated_script.trim(), "has_steps": has_numbered_steps, "model": model_id, "tokens_in": tokens_in, "tokens_out": tokens_out, "tool_count": tools.len(), }); println!( "{}", serde_json::to_string_pretty(&result) .map_err(|e| format!("JSON-serialisering feilet: {e}"))? ); Ok(()) } fn build_user_prompt( description: &str, trigger_event: Option<&str>, trigger_conditions: Option<&str>, ) -> String { let mut prompt = format!("Lag et orkestreringsscript for: {description}"); if let Some(event) = trigger_event { prompt.push_str(&format!("\n\nTrigger-event: {event}")); } if let Some(conditions) = trigger_conditions { prompt.push_str(&format!("\nBetingelser: {conditions}")); } prompt } fn strip_markdown_fences(text: &str) -> String { let trimmed = text.trim(); if trimmed.starts_with("```") { let lines: Vec<&str> = trimmed.lines().collect(); if lines.len() >= 2 { let start = 1; let end = if lines.last().map(|l| l.trim()) == Some("```") { lines.len() - 1 } else { lines.len() }; return lines[start..end].join("\n"); } } trimmed.to_string() } async fn save_work_item( db: &sqlx::PgPool, description: &str, trigger_event: Option<&str>, trigger_conditions: Option<&str>, requested_by: Uuid, collection_id: Option, ) -> Result<(), String> { let work_item_id = Uuid::now_v7(); let title = format!("AI-script: {}", truncate(description, 80)); let metadata = serde_json::json!({ "work_item_type": "script_request", "description": description, "trigger_event": trigger_event, "trigger_conditions": trigger_conditions, }); sqlx::query( r#" INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by) VALUES ($1, 'content', $2, $3, 'hidden'::visibility, $4, $5) "#, ) .bind(work_item_id) .bind(&title) .bind(description) .bind(&metadata) .bind(requested_by) .execute(db) .await .map_err(|e| format!("PG insert work_item feilet: {e}"))?; let tag_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, $2, 'tagged', '{"tag": "script_request"}'::jsonb, true, $3) "#, ) .bind(tag_edge_id) .bind(work_item_id) .bind(requested_by) .execute(db) .await .map_err(|e| format!("PG insert tagged-edge feilet: {e}"))?; if let Some(coll_id) = collection_id { let belongs_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', '{}'::jsonb, false, $4) "#, ) .bind(belongs_edge_id) .bind(work_item_id) .bind(coll_id) .bind(requested_by) .execute(db) .await .map_err(|e| format!("PG insert belongs_to-edge feilet: {e}"))?; } tracing::info!( work_item_id = %work_item_id, "Work item opprettet for script-forespørsel" ); let result = serde_json::json!({ "status": "deferred", "work_item_id": work_item_id.to_string(), "title": title, "message": "Forespørselen er lagret som work_item. Claude Code vil generere scriptet i neste sesjon.", }); println!( "{}", serde_json::to_string_pretty(&result) .map_err(|e| format!("JSON-serialisering feilet: {e}"))? ); Ok(()) } fn truncate(s: &str, max_len: usize) -> &str { if s.len() <= max_len { s } else { let mut end = max_len; while end > 0 && !s.is_char_boundary(end) { end -= 1; } &s[..end] } }