From 8436f75d874e755d2cae775237f43208461dd37a Mon Sep 17 00:00:00 2001 From: vegard Date: Wed, 18 Mar 2026 19:51:44 +0000 Subject: [PATCH] synops-ai prompt: direkte LLM-kall via LiteLLM (oppgave 28.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Legger til `synops-ai prompt`-subkommando for enkel prompt-inn/tekst-ut bruk av LLM via LiteLLM gateway. Eksisterende script-generering flyttes til `synops-ai script`-subkommando. Prompt-modus: - --prompt (påkrevd): bruker-prompt - --model (valgfri): modellalias, slås opp fra ai_job_routing hvis utelatt - --system (valgfri): systemprompt - --job-type: for modelloppslag og logging (default: simple_prompt) - --temperature: LLM-temperatur (default: 0.7) - Logger tokenbruk i ai_usage_log Seed: simple_prompt → sidelinja/rutine i ai_job_routing. --- migrations/008_ai_model_routing.sql | 3 +- tasks.md | 3 +- tools/README.md | 2 +- tools/synops-ai/src/main.rs | 409 ++++++++++++++++++---------- 4 files changed, 262 insertions(+), 155 deletions(-) diff --git a/migrations/008_ai_model_routing.sql b/migrations/008_ai_model_routing.sql index bf82898..9730184 100644 --- a/migrations/008_ai_model_routing.sql +++ b/migrations/008_ai_model_routing.sql @@ -80,7 +80,8 @@ INSERT INTO ai_job_routing (job_type, alias, description) VALUES ('whisper_postprocess', 'sidelinja/rutine', 'Transkripsjonsvasking etter Whisper'), ('research_clip', 'sidelinja/rutine', 'Research-oppsummering'), ('live_factoid_eval', 'sidelinja/resonering', 'Faktoid-vurdering under live sending — krever presisjon'), - ('agent_respond', 'sidelinja/resonering', 'Claude chat-agent svar'); + ('agent_respond', 'sidelinja/resonering', 'Claude chat-agent svar'), + ('simple_prompt', 'sidelinja/rutine', 'Standard LLM-kall via synops-ai prompt'); -- ============================================================================= -- 5. Oppdater agent_identities config til ny alias diff --git a/tasks.md b/tasks.md index 283e31b..df880ce 100644 --- a/tasks.md +++ b/tasks.md @@ -372,8 +372,7 @@ modell som brukes til hva. ### synops-ai: lettvekts LLM-kall -- [~] 28.1 `synops-ai` CLI: direkte LLM-kall via LiteLLM. Input: `--prompt [--model ] [--system ]`. Output: tekst til stdout. Ingen fillesing, ingen verktøy, bare prompt inn/ut. Bruker `ai_job_routing`-tabellen for å bestemme modell hvis `--model` ikke er satt. Logger i `ai_usage_log`. - > Påbegynt: 2026-03-18T19:45 +- [x] 28.1 `synops-ai` CLI: direkte LLM-kall via LiteLLM. Input: `--prompt [--model ] [--system ]`. Output: tekst til stdout. Ingen fillesing, ingen verktøy, bare prompt inn/ut. Bruker `ai_job_routing`-tabellen for å bestemme modell hvis `--model` ikke er satt. Logger i `ai_usage_log`. - [ ] 28.2 AI-rutingskontroll i admin: utvid admin-UI (fase 15.4) med konfigurasjon av hvilken modell som brukes per kontekst. Tabellen `ai_job_routing` mapper `(job_type, context)` → `model_alias`. Kontekster: `orchestration_script`, `orchestration_dream`, `bot_chat`, `bot_triage`, `summarize`, `suggest_edges`, `classify`. Admin kan endre uten redeploy. - [ ] 28.3 Kostnadstak per bruker/samling: `ai_budget`-felt i metadata for brukere og samlinger. `synops-ai` sjekker budsjett mot `ai_usage_log` aggregat før kall. Ved overskridelse: returner feilmelding, opprett work_item. diff --git a/tools/README.md b/tools/README.md index a4813f5..84b8123 100644 --- a/tools/README.md +++ b/tools/README.md @@ -21,7 +21,7 @@ eller maskinrommet-API. Ligger i PATH via symlink eller direkte kall. | `synops-tasks` | Parse tasks.md og vis oppgavestatus (filtrering på fase/status) | Ferdig | | `synops-feature-status` | Sjekk feature-status: spec, oppgaver, commits, feedback | Ferdig | | `synops-node` | Hent/vis en node med edges (UUID, --depth, --format json/md) | Ferdig | -| `synops-ai` | AI-assistert generering av orkestreringsscript fra fritekst | Ferdig | +| `synops-ai` | LLM-verktøy: `prompt` (direkte LLM-kall) + `script` (orkestreringsscript fra fritekst) | Ferdig | | `synops-clip` | Hent og parse webartikler (Readability + Playwright-fallback, paywall-deteksjon) | Ferdig | | `synops-mail` | Send epost via msmtp (vaktmester@synops.no) | Ferdig (venter SMTP-credentials) | diff --git a/tools/synops-ai/src/main.rs b/tools/synops-ai/src/main.rs index 6004bea..9e257c4 100644 --- a/tools/synops-ai/src/main.rs +++ b/tools/synops-ai/src/main.rs @@ -1,34 +1,60 @@ -// synops-ai — AI-assistert oppretting av orkestreringsscript. +// synops-ai — LLM-verktøy for Synops-plattformen. // -// Leser cli_tool-noder fra PG, bygger en systemprompt med tilgjengelige -// verktøy og script-grammatikk, og bruker LLM til å generere et -// orkestreringsscript fra en fritekst-beskrivelse. -// -// Tre moduser: -// 1. Generer script: --description "..." [--trigger-event ...] -// 2. System prompt: --generate-system-prompt (skriver prompt til stdout) -// 3. Eventually-modus: --description "..." --eventually (lagrer som work_item) +// 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 -// AI_SCRIPT_MODEL — Modellalias (default: sidelinja/smart) -// -// Ref: docs/concepts/orkestrering.md § "Nivå 2: AI-assistert oppretting" -use clap::Parser; +use clap::{Parser, Subcommand}; use serde::{Deserialize, Serialize}; use std::process; use uuid::Uuid; -/// AI-assistert oppretting av orkestreringsscript. +/// synops-ai — LLM-verktøy for Synops #[derive(Parser)] -#[command( - name = "synops-ai", - about = "Generer orkestreringsscript fra fritekst-beskrivelse via LLM" -)] +#[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, + + /// 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, +} + +#[derive(Parser)] +struct ScriptArgs { /// Fritekst-beskrivelse av ønsket orkestrering #[arg(long)] description: Option, @@ -37,7 +63,7 @@ struct Cli { #[arg(long)] trigger_event: Option, - /// Trigger-betingelser som JSON (f.eks. '{"has_trait":"podcast"}') + /// Trigger-betingelser som JSON #[arg(long)] trigger_conditions: Option, @@ -45,7 +71,7 @@ struct Cli { #[arg(long)] generate_system_prompt: bool, - /// Eventually-modus: lagre forespørselen som work_item i stedet for synkront LLM-kall + /// Eventually-modus: lagre som work_item #[arg(long)] eventually: bool, @@ -67,7 +93,7 @@ struct ChatRequest { temperature: f32, } -#[derive(Serialize)] +#[derive(Serialize, Clone)] struct ChatMessage { role: String, content: String, @@ -100,6 +126,174 @@ struct MessageContent { 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?, + }; + + 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: sys.clone(), + }); + } + messages.push(ChatMessage { + role: "user".to_string(), + content: args.prompt.clone(), + }); + + 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 + 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 (model_alias, model_actual, prompt_tokens, completion_tokens, total_tokens, job_type) + VALUES ($1, $2, $3, $4, $5, $6)", + ) + .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(()) +} + +/// 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)] @@ -109,7 +303,6 @@ struct CliToolRow { metadata: Option, } -/// Informasjon om et CLI-verktøy hentet fra PG. struct ToolInfo { binary: String, description: String, @@ -151,7 +344,6 @@ fn extract_tool_info(row: &CliToolRow) -> Option { }) } -/// Bygg systemprompt fra cli_tool-noder og script-grammatikk. fn build_system_prompt(tools: &[ToolInfo]) -> String { let mut prompt = String::new(); @@ -166,7 +358,6 @@ fn build_system_prompt(tools: &[ToolInfo]) -> String { \n", ); - // Tilgjengelige verktøy prompt.push_str("TILGJENGELIGE VERKTØY:\n\n"); for tool in tools { prompt.push_str(&format!("- {}: {}\n", tool.binary, tool.description)); @@ -182,7 +373,6 @@ fn build_system_prompt(tools: &[ToolInfo]) -> String { prompt.push('\n'); } - // Script-grammatikk prompt.push_str( "SCRIPT-GRAMMATIKK (menneskelig lag):\n\ \n\ @@ -211,7 +401,6 @@ fn build_system_prompt(tools: &[ToolInfo]) -> String { \n", ); - // Trigger-events prompt.push_str( "KJENTE TRIGGER-EVENTS:\n\ - node.created — Ny node opprettet\n\ @@ -223,7 +412,6 @@ fn build_system_prompt(tools: &[ToolInfo]) -> String { \n", ); - // Eksempel prompt.push_str( "EKSEMPEL:\n\ Beskrivelse: \"Når en innspilling er ferdig, transkriber og oppsummer\"\n\ @@ -250,7 +438,6 @@ fn build_system_prompt(tools: &[ToolInfo]) -> String { \n", ); - // Instruksjoner prompt.push_str( "INSTRUKSJONER:\n\ 1. Les brukerens beskrivelse nøye.\n\ @@ -267,33 +454,17 @@ fn build_system_prompt(tools: &[ToolInfo]) -> String { prompt } -#[tokio::main] -async fn main() { - synops_common::logging::init("synops_ai"); - - let cli = Cli::parse(); - - // Validering - if !cli.generate_system_prompt && cli.description.is_none() { - eprintln!("Feil: --description eller --generate-system-prompt er påkrevd"); - process::exit(1); +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 cli.eventually && cli.requested_by.is_none() { - eprintln!("Feil: --requested-by er påkrevd sammen med --eventually"); - process::exit(1); + if args.eventually && args.requested_by.is_none() { + return Err("--requested-by er påkrevd sammen med --eventually".into()); } - 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?; - // Hent alle cli_tool-noder let rows = sqlx::query_as::<_, CliToolRow>( "SELECT title, metadata FROM nodes WHERE node_kind = 'cli_tool' ORDER BY title", ) @@ -306,66 +477,73 @@ async fn run(cli: Cli) -> Result<(), String> { let system_prompt = build_system_prompt(&tools); - // Modus 1: Bare skriv ut systemprompt - if cli.generate_system_prompt { + if args.generate_system_prompt { println!("{system_prompt}"); return Ok(()); } - let description = cli.description.as_deref().unwrap(); + let description = args.description.as_deref().unwrap(); - // Modus 3: Eventually — lagre som work_item - if cli.eventually { + if args.eventually { return save_work_item( &db, description, - cli.trigger_event.as_deref(), - cli.trigger_conditions.as_deref(), - cli.requested_by.unwrap(), - cli.collection_id, + args.trigger_event.as_deref(), + args.trigger_conditions.as_deref(), + args.requested_by.unwrap(), + args.collection_id, ) .await; } - // Modus 2: Synkron AI-generering + // Synkron script-generering let user_content = build_user_prompt( description, - cli.trigger_event.as_deref(), - cli.trigger_conditions.as_deref(), + 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: system_prompt, + }, + ChatMessage { + role: "user".to_string(), + content: user_content, + }, + ]; + let (generated_script, llm_usage, llm_model) = - call_llm(&system_prompt, &user_content).await?; + call_llm_with(&model, &messages, 0.3).await?; - tracing::info!( - script_len = generated_script.len(), - "Script generert fra LLM" - ); + // Strip eventuelle markdown-blokker + let generated_script = strip_markdown_fences(&generated_script); - // Enkel sjekk om scriptet har nummererte steg - // Full validering skjer i vaktmesteren via script_compiler - 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('.') - }); + 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()); - // Logg AI-forbruk 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(cli.collection_id) // target_node_id (kan være null) - .bind(cli.requested_by) + .bind(args.collection_id) + .bind(args.requested_by) .bind("ai") .bind(serde_json::json!({ "model_id": model_id, @@ -379,7 +557,6 @@ async fn run(cli: Cli) -> Result<(), String> { tracing::warn!(error = %e, "Kunne ikke logge AI-ressursforbruk"); } - // Bygg resultat let result = serde_json::json!({ "status": "completed", "script": generated_script.trim(), @@ -399,7 +576,6 @@ async fn run(cli: Cli) -> Result<(), String> { Ok(()) } -/// Bygg bruker-prompt med beskrivelse og trigger-info. fn build_user_prompt( description: &str, trigger_event: Option<&str>, @@ -415,75 +591,12 @@ fn build_user_prompt( prompt } -/// Kall LiteLLM for script-generering. Returnerer (script, usage, model). -async fn call_llm( - system_prompt: &str, - user_content: &str, -) -> 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 model = - std::env::var("AI_SCRIPT_MODEL").unwrap_or_else(|_| "sidelinja/rutine".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.3, - }; - - 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(60)) - .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")?; - - // Strip eventuelle markdown-blokker som LLM kan ha lagt til - let cleaned = strip_markdown_fences(content); - - Ok((cleaned, chat_resp.usage, chat_resp.model)) -} - -/// Fjern markdown code fences hvis LLM wrapper scriptet i ```...``` 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; // Hopp over åpnings-fence + let start = 1; let end = if lines.last().map(|l| l.trim()) == Some("```") { lines.len() - 1 } else { @@ -495,7 +608,6 @@ fn strip_markdown_fences(text: &str) -> String { trimmed.to_string() } -/// Lagre forespørselen som work_item for Claude Code (eventually-modus). async fn save_work_item( db: &sqlx::PgPool, description: &str, @@ -514,7 +626,6 @@ async fn save_work_item( "trigger_conditions": trigger_conditions, }); - // Opprett work_item-node sqlx::query( r#" INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by) @@ -530,7 +641,6 @@ async fn save_work_item( .await .map_err(|e| format!("PG insert work_item feilet: {e}"))?; - // Legg til tagged-edge med "script_request" let tag_edge_id = Uuid::now_v7(); sqlx::query( r#" @@ -545,7 +655,6 @@ async fn save_work_item( .await .map_err(|e| format!("PG insert tagged-edge feilet: {e}"))?; - // Knytt til samling hvis oppgitt if let Some(coll_id) = collection_id { let belongs_edge_id = Uuid::now_v7(); sqlx::query( @@ -584,12 +693,10 @@ async fn save_work_item( Ok(()) } -/// Trunkér en streng til maks lengde. fn truncate(s: &str, max_len: usize) -> &str { if s.len() <= max_len { s } else { - // Finn nærmeste UTF-8-grense let mut end = max_len; while end > 0 && !s.is_char_boundary(end) { end -= 1;