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
978 lines
29 KiB
Rust
978 lines
29 KiB
Rust
// 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.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<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]
|
||
}
|
||
}
|