synops/tools/synops-ai/src/main.rs
vegard 1d8ebf259b Skjermklipp-input: paste screenshot i chat → CAS → media-node (oppgave 29.1)
Frontend:
- ChatInput: paste-handler detekterer bilder fra clipboard (ClipboardEvent),
  laster opp til CAS via uploadMedia med metadata_extra { source: "screenshot" }
- Chat-side: viser bildenoder inline med AI-beskrivelse når tilgjengelig
- api.ts: uploadMedia støtter nå metadata_extra for ekstra node-metadata

Backend (maskinrommet):
- upload_media: nytt metadata_extra multipart-felt som merges inn i
  media-nodens metadata (f.eks. source, description)
- describe_image: ny jobbtype — enqueuues automatisk for screenshot-uploads,
  kaller synops-ai med --image for AI-beskrivelse av bildet
- Beskrivelsen lagres tilbake i media-nodens metadata.description

synops-ai:
- Nytt --image flag for multimodal LLM-kall (vision) via LiteLLM
- Sender bilde som base64 data-URL i OpenAI-kompatibelt format
- Brukes av describe_image-jobben for bildbeskrivelse
2026-03-18 21:07:00 +00:00

978 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<String>,
/// Systemprompt (valgfri)
#[arg(long)]
system: Option<String>,
/// Bilde-fil å inkludere som visuell kontekst (multimodal/vision).
/// Bildet sendes som base64 data-URL til LLM.
#[arg(long)]
image: Option<String>,
/// 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.02.0, default: 0.7)
#[arg(long, default_value = "0.7")]
temperature: f32,
/// Samlings-ID for budsjettsjekk og logging
#[arg(long)]
collection_id: Option<Uuid>,
/// Bruker-ID som utløste kallet (for budsjettsjekk og logging)
#[arg(long)]
user_id: Option<Uuid>,
}
#[derive(Parser)]
struct ScriptArgs {
/// Fritekst-beskrivelse av ønsket orkestrering
#[arg(long)]
description: Option<String>,
/// Trigger-event for scriptet (f.eks. "communication.ended")
#[arg(long)]
trigger_event: Option<String>,
/// Trigger-betingelser som JSON
#[arg(long)]
trigger_conditions: Option<String>,
/// 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<Uuid>,
/// Samlings-ID å knytte work_item til
#[arg(long)]
collection_id: Option<Uuid>,
}
// --- LLM request/response (OpenAI-kompatibel) ---
#[derive(Serialize)]
struct ChatRequest {
model: String,
messages: Vec<ChatMessage>,
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<ContentPart>),
}
#[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<String>,
}
impl Serialize for MessageContent {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
MessageContent::Text(s) => serializer.serialize_str(s),
MessageContent::Multimodal(parts) => parts.serialize(serializer),
}
}
}
#[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: ResponseMessage,
}
#[derive(Deserialize)]
struct ResponseMessage {
content: Option<String>,
}
#[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<serde_json::Value> =
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<String> =
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<Uuid>,
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<String, String> {
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<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 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<String>,
metadata: Option<serde_json::Value>,
}
struct ToolInfo {
binary: String,
description: String,
aliases: Vec<String>,
args_hints: Vec<(String, String)>,
}
fn extract_tool_info(row: &CliToolRow) -> Option<ToolInfo> {
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<String> = 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 <event i naturlig språk>\n\
HVIS <betingelse i naturlig språk>\n\
\n\
<N>. <verb> <objekt> [(<argument1>, <argument2>)]\n\
<N+1>. <verb> <objekt>\n\
\n\
ved feil: opprett oppgave \"<tittel>\" (<tag>)\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<ToolInfo> = 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<Uuid>,
) -> 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]
}
}